chore(repo): restore main from the verified production source baseline (#1)

Preserve production history and recovered source changes, with portable repository validation.

Production snapshot and previous main are retained by tags. This merge updates source management only; no production deployment or data migration was performed.
This commit is contained in:
WuYongJie 2026-10-30 16:39:57 +08:00
commit be642948ec
1045 changed files with 146820 additions and 15355 deletions

View File

@ -153,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues
# -----------------------------------------------------------------------------
CANVAS_IMAGE_TASK_CONCURRENCY=3
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
CANVAS_IMAGE_TASK_CONCURRENCY=0
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

19
.gitattributes vendored Normal file
View File

@ -0,0 +1,19 @@
# Keep source checkouts consistent across Windows and Linux.
.gitattributes text eol=lf
.gitignore text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.json text eol=lf
*.jsonc text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf

34
.gitignore vendored
View File

@ -13,6 +13,7 @@ coverage
.next/
.next-build/
.next-previous/
/.next-*/
out/
build
@ -50,6 +51,7 @@ yarn-error.log*
/playwright-report/
/blob-report/
/playwright/*
/output/
deploy.tar.gz
libvips-8.14.5-linux-x64.tar.br
.temp-packages/
@ -66,10 +68,19 @@ migration-backup-complete/
migration-*.log
migration-*.txt
# Local release backups, model credentials and migration scratch data.
/.model-config-backups/
/backups/
/tmp/
# 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/
analysis/qiaodou-extract/
# 本地运行时数据库与上传文件
.local/
public/uploads/local/
# 模型文件
*.model
*.pth
@ -100,3 +111,26 @@ public/vendor/zoo-modeling-app/
# Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql
# Private smart edit runtime and media/model caches
.venv-smart-edit/
.smart-edit-models/
.cache/smart-edit/
services/video-render/dist/
# Downloaded and normalized subtitle fonts are runtime assets.
.cache/smart-edit-fonts/
services/smart-edit/__pycache__/
# Local verification artefacts (also excluded from Tailwind's automatic source scan).
.qa/
.next-video-*/
.next-tvstar-*/
services/video-upscale/dist/
services/video-upscale/__pycache__/
services/video-upscale/vendor/__pycache__/
/services/audio-processing/dist/
/services/audio-processing/__pycache__/

View File

@ -94,7 +94,7 @@ pnpm db:generate
推荐提交前至少运行:
```bash
git fetch github main --prune
git fetch origin --prune
pnpm check:branch
pnpm check:quality
pnpm audit:security

View File

@ -665,7 +665,7 @@ export function LocalAiImagePipelineSections({
</p>
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
<p>
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)
• 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
</p>
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
</div>
@ -784,7 +784,7 @@ export function LocalAiImagePipelineSections({
<p>• 支持2x、4x、8x放大倍数</p>
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
<p>• 文件限制:最大50MB</p>
<p>• 文件限制:最大20MB</p>
</div>
</div>
</>
@ -2377,13 +2377,10 @@ export function LocalAiImagePipelineSections({
items={[
{
label:
extendConfig.provider === "vip98k" || extendConfig.apiKey
? "Key 已配置"
: "Key 待配置",
tone:
extendConfig.provider === "vip98k" || extendConfig.apiKey
? "enabled"
: "warning",
extendConfig.provider === "vip98k"
? "VIP98K 服务商"
: "阿里云服务商",
tone: "enabled",
},
{
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
@ -2400,22 +2397,11 @@ export function LocalAiImagePipelineSections({
VIP98K 服务商读取,仅在这里维护扩展比例。
</div>
) : (
<div className="space-y-2">
<Label htmlFor="extendApiKey">API密钥 *</Label>
<Input
id="extendApiKey"
onChange={(e) =>
setExtendConfig({ ...extendConfig, apiKey: e.target.value })
}
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={extendConfig.apiKey}
/>
<div className="text-muted-foreground text-xs">
请输入阿里云Dashscope API密钥,例如:
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用阿里云 DashScope 的 image-out-painting 模型。API Key
从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
仅在这里维护拓展比例。
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
@ -2470,7 +2456,7 @@ export function LocalAiImagePipelineSections({
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
</p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:4MB</p>
<p>• 最大图片大小:10MB</p>
<p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div>

View File

@ -1078,139 +1078,15 @@ export function LocalAiRuntimeSections({
</Button>
</LocalAiSectionShell>
{/* 全站生图单模型并发 */}
<LocalAiSectionShell
description={
<>
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
</>
}
description="生图任务直接发起,图片先显示,后台转存到存储桶。"
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
id="local-ai-section-canvas-image-task-queue"
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
summary={
<LocalAiSectionSummary
items={[
{
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
tone: "neutral",
},
{
label: `${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒补位`,
tone: "neutral",
},
{ label: "全站生图生效", tone: "enabled" },
]}
/>
}
title={
<span className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
全站生图单模型并发与批次间隔
</span>
}
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
<div className="font-medium">生效范围</div>
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
<p>
•
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
</p>
<p>
•
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
</p>
<p>
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
</p>
<p>
•
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
</p>
<p>
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
池继续细分。
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="canvasImageTaskQueueConcurrency">
单模型最大同时运行任务数
</Label>
<Input
id="canvasImageTaskQueueConcurrency"
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
concurrency: e.target.value,
})
)
}
type="number"
value={canvasImageTaskQueueConfig.concurrency}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
下一批排队任务发起间隔(秒)
</Label>
<Input
id="canvasImageTaskBatchDispatchIntervalSeconds"
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
batchDispatchIntervalMs: Math.round(
Number.parseFloat(e.target.value || "0") * 1000
),
})
)
}
step="0.5"
type="number"
value={String(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
)}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
{(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)}{" "}
秒,默认值为{" "}
{(
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
).toFixed(1)}{" "}
秒。
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
</Button>
</div>
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
</LocalAiSectionShell>
{/* 批量处理配置 */}

View File

@ -275,7 +275,6 @@ export type EnhanceConfig = {
export type ExtendConfig = {
provider: "vip98k" | "dashscope";
apiKey: string;
xScale: string;
yScale: string;
};

View File

@ -38,6 +38,7 @@ import {
ImagePlus,
Key,
Layers,
LayoutTemplate,
Layers3,
Loader2,
type LucideIcon,
@ -1061,6 +1062,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
imageRemoveBackground: Scissors,
imageBoxCutout: Scan,
imageLocalEdit: Wand2,
imageLayoutReplace: LayoutTemplate,
imageRemoveWatermark: Eraser,
imageResize: Maximize2,
imageTextEdit: Type,
@ -2180,7 +2182,7 @@ export function AdminSettingsClient() {
usageTimeEnabled: false,
usageTimeStart: "08:00",
usageTimeEnd: "20:00",
usageTimeMessage: "由壹点累了也是需要休息的",
usageTimeMessage: "BuYi 累了也是需要休息的",
defaultTheme: "system",
bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
@ -2222,7 +2224,7 @@ export function AdminSettingsClient() {
agentWorkflowDefaultModelId: null,
canvasDetailDefaultModelId: null,
canvasDetailAgentModelId: null,
lovartComposerPrefix: "让「由壹点AI」",
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
lovartComposerPreviewTexts: [
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
@ -2238,8 +2240,8 @@ export function AdminSettingsClient() {
lovartPromoText:
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: "下达指令,即刻呈现",
surpriseCategories: ["全部", "精选"],
membershipConfig: defaultMembershipConfig,
membershipUserOverrides: defaultMembershipUserOverrides,
@ -2300,7 +2302,7 @@ export function AdminSettingsClient() {
usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
usageTimeMessage:
siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的",
siteSettingsData.usageTimeMessage || "BuYi 累了也是需要休息的",
defaultTheme: siteSettingsData.defaultTheme || "system",
bannerEnabled: siteSettingsData.bannerEnabled ?? true,
bannerText:
@ -2396,7 +2398,7 @@ export function AdminSettingsClient() {
(siteSettingsData as any).canvasDetailAgentModelId || null,
lovartComposerPrefix:
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
"让「由壹点AI」",
"让「𝗕𝘂𝗬𝗶」",
lovartComposerPreviewTexts: Array.isArray(
siteSettingsData.lovartComposerPreviewTexts
)
@ -2498,9 +2500,9 @@ export function AdminSettingsClient() {
lovartPromoActionText:
siteSettingsData.lovartPromoActionText || "立即升级",
lovartHeroTitle:
siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单",
siteSettingsData.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle:
siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
siteSettingsData.lovartHeroSubtitle || "下达指令,即刻呈现",
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
? siteSettingsData.surpriseCategories
.map((item: any) => String(item).trim())
@ -2631,8 +2633,7 @@ export function AdminSettingsClient() {
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
provider: "vip98k" as "vip98k" | "dashscope",
apiKey: "",
provider: "dashscope" as "vip98k" | "dashscope",
xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数
});
@ -2951,13 +2952,11 @@ export function AdminSettingsClient() {
if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as {
provider?: "vip98k" | "dashscope";
apiKey?: string;
xScale?: number;
yScale?: number;
};
setExtendConfig({
provider: config.provider === "vip98k" ? "vip98k" : "dashscope",
apiKey: config.apiKey || "",
xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5",
});
@ -5554,11 +5553,6 @@ export function AdminSettingsClient() {
// --- 图像拓展API配置逻辑 ---
const handleSaveExtendConfig = async () => {
try {
if (extendConfig.provider !== "vip98k" && !extendConfig.apiKey.trim()) {
toast.error("请输入API密钥");
return;
}
const xScale = Number.parseFloat(extendConfig.xScale);
const yScale = Number.parseFloat(extendConfig.yScale);
@ -5579,7 +5573,6 @@ export function AdminSettingsClient() {
key: "extend_api_config",
value: {
provider: extendConfig.provider,
apiKey: extendConfig.apiKey.trim(),
xScale,
yScale,
},
@ -8913,9 +8906,7 @@ export function AdminSettingsClient() {
{
key: "realtime-canvas",
title: "实时与队列",
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒`,
description: "生图并发不限,任务即时发起",
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
icon: SlidersHorizontal,
@ -8998,7 +8989,7 @@ export function AdminSettingsClient() {
key: "extend",
label: "图像拓展",
group: "图像处理",
tone: extendConfig.apiKey ? "enabled" : "warning",
tone: "enabled",
},
{
key: "watermark-remove",
@ -10522,7 +10513,7 @@ export function AdminSettingsClient() {
lovartComposerPrefix: e.target.value.slice(0, 40),
})
}
placeholder="让「由壹点AI」"
placeholder="让「𝗕𝘂𝗬𝗶」"
value={siteSettings.lovartComposerPrefix}
/>
<div className="text-muted-foreground text-xs">
@ -10724,7 +10715,7 @@ export function AdminSettingsClient() {
lovartHeroTitle: e.target.value,
})
}
placeholder="由壹点AI 让设计更简单"
placeholder="𝗕𝘂𝗬𝗶 让设计触手可及"
value={siteSettings.lovartHeroTitle}
/>
</div>
@ -10738,7 +10729,7 @@ export function AdminSettingsClient() {
lovartHeroSubtitle: e.target.value,
})
}
placeholder="懂你的设计代理,帮你搞定一切"
placeholder="下达指令,即刻呈现"
value={siteSettings.lovartHeroSubtitle}
/>
</div>

View File

@ -1,4 +1,5 @@
"use client";
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
@ -24,10 +25,6 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { fetcher } from "@/lib/utils";
import {
getVideoCanvasFunctionBillingCopy,
@ -87,56 +84,23 @@ function getModelOptionLabel(model: AiModelOption) {
return `${name}${id}`;
}
function isImageModelOption(model: AiModelOption) {
return isImageGenerationModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function isVideoModelOption(model: AiModelOption) {
return isVideoModelDescriptor({
providerType: model.providerType,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
function filterModelsForFunctionKind(
models: AiModelOption[],
kind: VideoCanvasFunctionDefinition["kind"],
) {
const enabled = models.filter((model) => model.isEnabled !== false && getModelOptionValue(model));
let filtered = enabled;
if (kind === "image" || kind === "image-edit") {
filtered = enabled.filter(isImageModelOption);
} else if (kind === "video") {
filtered = enabled.filter(isVideoModelOption);
} else if (kind === "multimodal") {
filtered = enabled.filter(
(model) => model.isMultimodal || model.supportsVision || (!isImageModelOption(model) && !isVideoModelOption(model)),
);
}
return filtered.length ? filtered : enabled;
}
function ModelSelect({
allowInherit = false,
kind,
functionKey,
models,
onChange,
value,
}: {
allowInherit?: boolean;
kind: VideoCanvasFunctionDefinition["kind"];
functionKey?: string;
models: AiModelOption[];
onChange: (value: string) => void;
value: string;
}) {
const filteredModels = filterModelsForFunctionKind(models, kind);
const modelKind = functionKey === "audio-to-video" ? "audio-video" : kind === "image" || kind === "image-edit" || kind === "video" || kind === "multimodal" ? kind : "text";
const filteredModels = filterCanvasModels(models, modelKind);
const normalizedValue = value?.trim() || (allowInherit ? INHERIT_MODEL_VALUE : "auto");
const hasCurrent =
normalizedValue === "auto" ||
@ -851,13 +815,14 @@ export function VideoCanvasSettingsClient() {
<div className="grid gap-4 md:grid-cols-1">
<div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<ModelSelect
{["canvas-generate-image", "canvas-generate-video", "canvas-workflow-analyze", "pick-object"].includes(item.route) ? <ModelSelect
allowInherit
functionKey={item.key}
kind={item.kind}
models={modelOptions}
onChange={(value) => updateFunction(item.key, { model: value })}
value={item.model || ""}
/>
/> : <p className="text-sm text-muted-foreground">{item.route === "local" ? "本地处理,无需生成模型" : "使用此功能的专用服务接口"}</p>}
</div>
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 px-3 py-2">
<div className="text-xs font-medium text-foreground">

View File

@ -2,9 +2,31 @@ import { createLogger } from "@/lib/observability/logger";
import { generateText } from "ai";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
normalizeDetailReplacementImageModelConfig,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
compositeReplacementWithReferenceLocks,
parseReplacementLayoutLockRegions,
type NormalizedReplacementBox,
} from "@/lib/ai/replacement-layout-lock";
import {
buildReplacementProductIdentityAnalysisPrompt,
buildReplacementProductStructureLockLines,
formatReplacementProductIdentityProfile,
normalizeReplacementProductImageIndexes,
parseReplacementProductIdentityProfile,
type ReplacementProductIdentityProfile,
} from "@/lib/ai/replacement-product-identity";
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
import { resolveDetailReplacementVisionRuntime } from "@/lib/ai/detail-replacement-vision-runtime";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { getEnabledAiModelsWithProvider, getSiteSettings } from "@/lib/db/queries";
import {
getEnabledAiModelsWithProvider,
getSiteSettings,
getSystemSetting,
} from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
@ -234,6 +256,8 @@ export async function normalizeReplacementResultImage(params: {
referenceImageUrl: string;
referenceGeometry?: ReplacementReferenceGeometry | null;
scenePlan?: ReplacementScenePlan | null;
preserveReferenceOutsideSubject?: boolean;
preserveReferenceText?: boolean;
uploadPrefix: string;
}): Promise<{
originalUrl: string;
@ -242,6 +266,11 @@ export async function normalizeReplacementResultImage(params: {
}> {
const sharp = (await import("sharp")).default;
const generatedBuffer = await fetchImageBufferFromUrl(params.generatedImageUrl);
let referenceBuffer: Buffer | null = null;
const getReferenceBuffer = async () => {
referenceBuffer ||= await fetchImageBufferFromUrl(params.referenceImageUrl);
return referenceBuffer;
};
let targetWidth = params.referenceGeometry?.width;
let targetHeight = params.referenceGeometry?.height;
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
@ -250,8 +279,8 @@ export async function normalizeReplacementResultImage(params: {
if (!targetWidth || !targetHeight) {
try {
const referenceBuffer = await fetchImageBufferFromUrl(params.referenceImageUrl);
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" })
const resolvedReferenceBuffer = await getReferenceBuffer();
const referenceMetadata = await sharp(resolvedReferenceBuffer, { failOn: "none" })
.rotate()
.metadata();
targetWidth = referenceMetadata.width;
@ -298,7 +327,7 @@ export async function normalizeReplacementResultImage(params: {
: shouldPreserveFullGeneratedCanvas
? "contain"
: "cover";
const normalizedBuffer =
let normalizedBuffer =
targetWidth && targetHeight
? await imagePipeline
.resize(targetWidth, targetHeight, {
@ -312,6 +341,82 @@ export async function normalizeReplacementResultImage(params: {
.png()
.toBuffer()
: await imagePipeline.png().toBuffer();
if (
targetWidth &&
targetHeight &&
(params.preserveReferenceOutsideSubject || params.preserveReferenceText)
) {
let layoutLocks = {
subjectBoundingBox: params.scenePlan?.subjectBoundingBox || null,
textBoundingBoxes: params.scenePlan?.textBoundingBoxes || [],
analyzed: params.scenePlan?.layoutLockAnalyzed === true,
};
const needsLayoutLockFallback =
!layoutLocks.analyzed ||
(params.preserveReferenceOutsideSubject && !layoutLocks.subjectBoundingBox);
if (needsLayoutLockFallback) {
const fallbackLocks = await resolveReplacementLayoutLockRegions({
referenceImageUrl: params.referenceImageUrl,
referenceBuffer: await getReferenceBuffer(),
});
layoutLocks = {
subjectBoundingBox:
layoutLocks.subjectBoundingBox || fallbackLocks.subjectBoundingBox,
textBoundingBoxes:
layoutLocks.textBoundingBoxes.length > 0
? layoutLocks.textBoundingBoxes
: fallbackLocks.textBoundingBoxes,
analyzed: layoutLocks.analyzed || fallbackLocks.analyzed,
};
}
const shouldUseSafeReferenceFallback = Boolean(
(params.preserveReferenceOutsideSubject && !layoutLocks.subjectBoundingBox) ||
(params.preserveReferenceText && !layoutLocks.analyzed),
);
normalizedBuffer = shouldUseSafeReferenceFallback
? await sharp(await getReferenceBuffer(), { failOn: "none" })
.rotate()
.resize(targetWidth, targetHeight, { fit: "fill" })
.png()
.toBuffer()
: await compositeReplacementWithReferenceLocks({
generatedBuffer: normalizedBuffer,
referenceBuffer: await getReferenceBuffer(),
width: targetWidth,
height: targetHeight,
preserveReferenceOutsideSubject: Boolean(
params.preserveReferenceOutsideSubject && layoutLocks.subjectBoundingBox,
),
subjectBoundingBox: layoutLocks.subjectBoundingBox,
textBoundingBoxes: params.preserveReferenceText
? layoutLocks.textBoundingBoxes
: [],
});
if (shouldUseSafeReferenceFallback) {
chatApiLogger.warn(
"[Chat API] Replacement layout lock analysis unavailable; preserved the reference image instead of returning a layout-damaged result",
{
preserveReferenceOutsideSubject: params.preserveReferenceOutsideSubject,
preserveReferenceText: params.preserveReferenceText,
hasSubjectBoundingBox: Boolean(layoutLocks.subjectBoundingBox),
layoutLockAnalyzed: layoutLocks.analyzed,
},
);
}
chatApiLogger.info("[Chat API] Replacement reference layout locks applied:", {
preserveReferenceOutsideSubject: Boolean(
params.preserveReferenceOutsideSubject && layoutLocks.subjectBoundingBox,
),
preservedTextRegionCount: params.preserveReferenceText
? layoutLocks.textBoundingBoxes.length
: 0,
usedFallbackAnalysis: needsLayoutLockFallback,
usedSafeReferenceFallback: shouldUseSafeReferenceFallback,
});
}
const thumbnailBuffer = await createAgentImageThumbnailBuffer(normalizedBuffer);
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@ -399,6 +504,12 @@ export type ReplacementScenePlan = {
generationPrompt: string;
selectedProductImageIndexes: number[];
productSelectionReason: string;
productIdentitySummary?: string;
productStructureSummary?: string;
productStructuralRisks?: string;
subjectBoundingBox?: NormalizedReplacementBox | null;
textBoundingBoxes?: NormalizedReplacementBox[];
layoutLockAnalyzed?: boolean;
analysisModelName?: string | null;
analysisSource: "local_moonshot" | "configured_vision" | "heuristic";
};
@ -439,6 +550,188 @@ export type ReplacementScenePlanningRuntime =
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
const replacementProductIdentityProfileCache = new Map<
string,
{
expiresAt: number;
promise: Promise<ReplacementProductIdentityProfile | null>;
}
>();
async function resolveReplacementProductIdentityProfile(params: {
productImageUrls: string[];
planningRuntime: Exclude<ReplacementScenePlanningRuntime, null>;
}) {
const productImageUrls = Array.from(
new Set(
params.productImageUrls
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url)),
),
).slice(0, 8);
if (productImageUrls.length === 0) return null;
const cacheKey = [
params.planningRuntime.kind,
params.planningRuntime.modelName,
...productImageUrls,
].join("|");
const now = Date.now();
const cached = replacementProductIdentityProfileCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
if (replacementProductIdentityProfileCache.size >= 100) {
const oldestKey = replacementProductIdentityProfileCache.keys().next().value;
if (oldestKey) replacementProductIdentityProfileCache.delete(oldestKey);
}
const promise = (async () => {
try {
const { inlineDataUrls } = await inlineVisionImages(productImageUrls, {
maxImages: productImageUrls.length,
maxSide: 800,
timeoutMs: 12_000,
retries: 2,
loggerPrefix: "[Chat API] Replacement product identity",
trimTransparentPadding: true,
});
const effectiveImages =
inlineDataUrls.length === productImageUrls.length
? inlineDataUrls
: productImageUrls;
const { text } = await generateText({
model: params.planningRuntime.model,
messages: [
{
role: "user",
content: [
{
type: "text",
text: buildReplacementProductIdentityAnalysisPrompt(
productImageUrls.length,
),
},
...effectiveImages.map((image) => ({
type: "image" as const,
image,
})),
],
},
],
system:
"你是高精度商品结构分析器。多张图片属于同一商品;请跨视角还原唯一结构身份,并明确每个视角最清楚的构件。",
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
maxTokens: 1800,
abortSignal: AbortSignal.timeout(
params.planningRuntime.kind === "local_moonshot" ? 90_000 : 60_000,
),
} as any);
const profile = parseReplacementProductIdentityProfile(
text,
productImageUrls.length,
);
if (!profile) {
throw new Error("商品结构画像缺少必需字段");
}
return profile;
} catch (error) {
chatApiLogger.warn("[Chat API] Replacement product identity analysis failed:", {
productImageCount: productImageUrls.length,
modelName: params.planningRuntime.modelName,
error: error instanceof Error ? error.message : "unknown",
});
return null;
}
})();
replacementProductIdentityProfileCache.set(cacheKey, {
expiresAt: now + 30 * 60 * 1000,
promise,
});
return promise;
}
const replacementLayoutLockCache = new Map<
string,
{
expiresAt: number;
promise: Promise<ReturnType<typeof parseReplacementLayoutLockRegions>>;
}
>();
async function resolveReplacementLayoutLockRegions(params: {
referenceImageUrl: string;
referenceBuffer: Buffer;
}) {
const now = Date.now();
const cached = replacementLayoutLockCache.get(params.referenceImageUrl);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
if (replacementLayoutLockCache.size >= 200) {
const oldestKey = replacementLayoutLockCache.keys().next().value;
if (oldestKey) {
replacementLayoutLockCache.delete(oldestKey);
}
}
const promise = (async () => {
try {
const sharp = (await import("sharp")).default;
const inlineBuffer = await sharp(params.referenceBuffer, { failOn: "none" })
.rotate()
.resize(1600, 1600, {
fit: "inside",
withoutEnlargement: true,
})
.flatten({ background: "#ffffff" })
.jpeg({ quality: 88 })
.toBuffer();
const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl");
const result = await callQwen3Vl({
imageUrl: `data:image/jpeg;base64,${inlineBuffer.toString("base64")}`,
prompt: [
"你是电商详情图的布局区域定位器。",
"只分析当前图像,返回严格 JSON,不要解释。",
"所有坐标使用 0到1000 的归一化坐标,格式必须是 [x1,y1,x2,y2],绝对不是 x/y/宽/高。",
"subjectBoundingBox:只框出图中当前旧商品主体的可视外轮廓区域,包含完整商品和它的自然阴影,不要包含文字;没有商品则为 null。",
"textBoundingBoxes:框出所有可见文案块,包括中英文标题、卖点、参数、标签、数字、品牌字样、按钮文字和紧邻的图标;没有则返回空数组。",
'{"subjectBoundingBox":[0,0,1000,1000],"textBoundingBoxes":[[0,0,1000,1000]]}',
].join("\n"),
temperature: 0,
maxTokens: 1600,
timeoutMs: 60_000,
retries: 1,
});
const parsedLocks = parseReplacementLayoutLockRegions(result.text);
if (!parsedLocks.analyzed) {
throw new Error("布局区域定位结果缺少必需字段");
}
return parsedLocks;
} catch (error) {
replacementLayoutLockCache.delete(params.referenceImageUrl);
chatApiLogger.warn("[Chat API] Replacement layout lock fallback analysis failed:", {
referenceImageUrl: params.referenceImageUrl,
error: error instanceof Error ? error.message : "unknown",
});
return {
subjectBoundingBox: null,
textBoundingBoxes: [],
analyzed: false,
};
}
})();
replacementLayoutLockCache.set(params.referenceImageUrl, {
expiresAt: now + 30 * 60 * 1000,
promise,
});
return promise;
}
export function normalizeDetailReplacementStrategy(
value: unknown,
): AgentDetailReplacementStrategy {
@ -1441,7 +1734,27 @@ export function parseReplacementScenePlan(
visualProfile: params.visualProfile,
});
const productImageSelection = buildDefaultProductImageSelection(params.productImageCount);
const defaultProductImageSelection = buildDefaultProductImageSelection(
params.productImageCount,
);
const selectedProductImageIndexes = normalizeReplacementProductImageIndexes(
parsed.selectedProductImageIndexes ??
parsed.selectedProductIndexes ??
parsed.productImageIndexes,
params.productImageCount,
);
const productImageSelection =
selectedProductImageIndexes.length > 0
? {
selectedProductImageIndexes,
productSelectionReason:
truncateReplacementAnalysisText(
parsed.productSelectionReason ?? parsed.selectionReason,
240,
) || "按当前详情底板机位选择最匹配的产品视角。",
}
: defaultProductImageSelection;
const layoutLocks = parseReplacementLayoutLockRegions(parsed);
let scenePlan: ReplacementScenePlan = {
pageKind,
generationRoute,
@ -1486,6 +1799,9 @@ export function parseReplacementScenePlan(
generationPrompt,
selectedProductImageIndexes: productImageSelection.selectedProductImageIndexes,
productSelectionReason: productImageSelection.productSelectionReason,
subjectBoundingBox: layoutLocks.subjectBoundingBox,
textBoundingBoxes: layoutLocks.textBoundingBoxes,
layoutLockAnalyzed: layoutLocks.analyzed,
analysisModelName: null,
analysisSource: "heuristic",
};
@ -1844,6 +2160,27 @@ export async function resolveDirectReplacementModelRef(params: {
finalModelId?: string | null;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) {
const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const configuredModelSetting = await getSystemSetting({
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
}).catch(() => null);
const configuredModelId = normalizeDetailReplacementImageModelConfig(
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
if (configuredModel) {
return configuredModel.model.id;
}
const explicitModel = String(params.selectedChatModel || "").trim();
if (explicitModel && explicitModel !== "auto") {
return explicitModel;
@ -1853,13 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
return routedModel;
}
const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const preferred =
imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
@ -2121,6 +2451,16 @@ export async function analyzeReplacementReferenceImage(params: {
export async function resolveReplacementScenePlanningRuntime(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}): Promise<ReplacementScenePlanningRuntime> {
try {
const dedicatedVisionRuntime = await resolveDetailReplacementVisionRuntime();
if (dedicatedVisionRuntime) return dedicatedVisionRuntime;
} catch (error) {
chatApiLogger.warn(
"[Chat API] Failed to resolve dedicated detail replacement vision model:",
error,
);
}
try {
const siteSettings = await getSiteSettings().catch(() => null);
const localDetailAgentResolved = await resolveDetailAgentLocalMoonshotModel({
@ -2191,6 +2531,13 @@ export async function analyzeReplacementExecutionPlan(params: {
const referenceVisualProfile = isDetailReplacement
? await resolveReplacementReferenceVisualProfile(params.referenceImageUrl)
: null;
const productIdentityProfile =
params.planningRuntime && params.productImageUrls.length > 0
? await resolveReplacementProductIdentityProfile({
productImageUrls: params.productImageUrls,
planningRuntime: params.planningRuntime,
})
: null;
if (params.planningRuntime) {
try {
const replacementScenePlanningTimeoutMs =
@ -2221,7 +2568,10 @@ export async function analyzeReplacementExecutionPlan(params: {
? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。"
: "你是电商详情替换流程中的“场景理解规划器”。",
`当前要分析的参考图:${params.referenceLabel}`,
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。",
"当前视觉输入只包含这一张详情参考图;产品图已经单独做过跨视角结构分析,请根据下面的产品结构画像选择最匹配当前机位的产品视角,不要臆测产品结构。",
productIdentityProfile
? `【产品结构画像】\n${formatReplacementProductIdentityProfile(productIdentityProfile)}`
: "产品结构画像暂不可用:仍需按当前参考图机位选择产品图,但不得把旧产品的腿型、支架或构件拓扑当成新产品结构。",
referenceVisualProfile
? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, topLight=${referenceVisualProfile.topLightLowSaturationPixelRatio.toFixed(2)}, borderLight=${referenceVisualProfile.borderLightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}, likelyWhiteScene=${referenceVisualProfile.likelyWhiteScene ? "true" : "false"}。${referenceVisualProfile.reason}`
: "",
@ -2231,7 +2581,7 @@ export async function analyzeReplacementExecutionPlan(params: {
!referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene
? "像素初筛已经命中白/浅色低饱和真实场景:这不是白底说明页。若画面存在真实空间、人物或产品照片,promptPackPolicy 应为 soft 或 allow,必须使用前置工作流词包重建图片框内产品、空间、软装和灯光,使差异清晰可见;但最终仍保持白/浅色低饱和基调,不要改造成深色厨房、黑墙、复杂吊灯或强软装。"
: "",
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
"产品图和模特图会在后续生图阶段单独提供给生图模型。你必须依据产品结构画像输出 selectedProductImageIndexes,选择最多两张最能匹配当前参考图机位并共同说明完整结构的产品图;不得固定选择前两张,也不要要求强行使用全部产品图。",
params.modelImageUrl && !isProductOnlyDetailReplacement
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
: "本轮没有模特图,useModelImage 必须为 false。",
@ -2278,12 +2628,18 @@ export async function analyzeReplacementExecutionPlan(params: {
isDetailReplacement
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
: "",
isDetailReplacement
? "5a. 必须输出 subjectBoundingBox 和 textBoundingBoxes。所有坐标为 0到1000 归一化坐标 [x1,y1,x2,y2],不是 x/y/宽/高。subjectBoundingBox 紧密框住旧商品主体及自然阴影,不含文字,无商品时为 null;textBoundingBoxes 覆盖所有中英文文案、标题、卖点、参数、标签、数字、品牌字样、按钮文字及紧邻图标,无文字时为 []。"
: "",
isDetailReplacement
? "6. 判断 peoplePolicy:有人物/多人/自拍/聚会/围坐/聚餐/模特互动/拿手机/用餐/试坐/承重动作时必须 preserve_relation;如果文字明确写了“多人围坐、聚餐、合照、用餐、单侧承重、横向受力测试、拿手机”等动作,generationPrompt 必须把这个动作写成硬约束;如果只提供 1 张模特图但参考图多人,仍要保留参考图人数和关系,额外人物按参考图生成相近人群。"
: "",
isDetailReplacement
? "7. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary;cameraSummary 必须写出产品主体的视角特征,例如正侧视/三分之四/俯视/平视、倾斜方向、可见面数量、主体朝向和透视消失方向。"
: "",
isDetailReplacement
? "7a. 输出 selectedProductImageIndexes:根据当前详情图机位,从产品结构画像的图片编号中选择最多两张最相关的产品图;第一张应最接近当前机位,第二张补足腿、支架、横梁、侧框或其他容易变形的结构。输出 productSelectionReason 说明选择依据。"
: "",
isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement
@ -2309,7 +2665,7 @@ export async function analyzeReplacementExecutionPlan(params: {
: "",
"只返回严格 JSON,不要任何额外解释。",
`JSON 格式:
{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","pageKind":"prompt_pack_scene|white_scene|white_layout|copy_locked|people_scene|action_poster|dimension_spec|material_detail|unknown","generationRoute":"${isDetailReplacement ? "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked" : "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough"}","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`,
{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字","pageKind":"prompt_pack_scene|white_scene|white_layout|copy_locked|people_scene|action_poster|dimension_spec|material_detail|unknown","generationRoute":"${isDetailReplacement ? "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked" : "prompt_pack_scene|layout_preserve_replace|copy_locked_regenerate|people_relation_locked|direct_passthrough"}","promptPackPolicy":"allow|soft|forbid","textPolicy":"free|preserve|lock","peoplePolicy":"none|preserve_relation|require_user_model","whiteBackgroundLikely":false,"copyImageCoupled":false,"containsPeople":false,"containsMultiplePeople":false,"subjectBoundingBox":[0,0,1000,1000],"textBoundingBoxes":[[0,0,1000,1000]],"selectedProductImageIndexes":[1,2],"productSelectionReason":"不超过80字","routeReason":"不超过50字","useModelImage":false,"modelImageReason":"不超过40字","sceneSummary":"不超过80字","cameraSummary":"不超过60字","placementSummary":"不超过60字","preserveSummary":"不超过80字","styleSummary":"不超过80字","avoidSummary":"不超过80字","generationPrompt":"不超过300字"}`,
]
.filter(Boolean)
.join("\n");
@ -2334,7 +2690,7 @@ export async function analyzeReplacementExecutionPlan(params: {
system:
"你是高精度商品详情图场景规划器。你只分析当前参考图本身,负责理解这一屏的场景、机位、主体位置和人物需求,不要臆测或依赖其他输入图片。",
temperature: params.planningRuntime.kind === "local_moonshot" ? 1 : 0.1,
maxTokens: 1200,
maxTokens: 1600,
abortSignal: AbortSignal.timeout(replacementScenePlanningTimeoutMs),
} as any);
@ -2368,6 +2724,9 @@ export async function analyzeReplacementExecutionPlan(params: {
: "configured_vision",
scenePlan: {
...normalizedPlan.scenePlan,
productIdentitySummary: productIdentityProfile?.identitySummary || "",
productStructureSummary: productIdentityProfile?.structureSummary || "",
productStructuralRisks: productIdentityProfile?.structuralRisks || "",
analysisModelName: params.planningRuntime.modelName,
analysisSource:
params.planningRuntime.kind === "local_moonshot"
@ -2413,7 +2772,12 @@ export async function analyzeReplacementExecutionPlan(params: {
);
return {
...normalizedFallbackPlan.decision,
scenePlan: normalizedFallbackPlan.scenePlan,
scenePlan: {
...normalizedFallbackPlan.scenePlan,
productIdentitySummary: productIdentityProfile?.identitySummary || "",
productStructureSummary: productIdentityProfile?.structureSummary || "",
productStructuralRisks: productIdentityProfile?.structuralRisks || "",
},
};
}
@ -2537,11 +2901,17 @@ export function buildAgentReplacementPrompt(params: {
]
: [];
const productAngleLockLines = [
"【产品角度锁定规则】最终商品/主体的拍摄角度以最后一张参考图为准,而不是以前面的产品输入图为准;前面的产品图只提供款式、材质、颜色、结构和细节。",
"必须逐项对齐最后一张参考图中的产品姿态:朝向、正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向、可见侧面数量、支架/腿部/把手/转轴位置、主体外轮廓、占比、重心和透视消失方向。",
"【产品角度锁定规则】最终商品/主体的拍摄角度、朝向、占比、重心和透视消失方向以最后一张参考图为准;商品身份、外轮廓、腿型、支架拓扑、构件数量和连接方式以前面的产品输入图为准。",
"必须对齐最后一张参考图的正侧视/三分之四/俯视/平视关系、桌面或面板倾斜方向和可见侧面数量,但不得照搬旧商品的腿部、横梁、侧框、把手、转轴或内部构件设计。",
"如果最后一张参考图的产品是侧视或低视角,不允许改成俯视三分之四;如果是俯视三分之四,不允许改成正侧视;不允许镜像、旋转、翻转或把产品重画成另一个角度。",
"若参考图含功能标签、箭头、尺寸线、编号或引导线,它们指向的实际结构部位必须保持一致,不得因产品转角改变而让标签指向错误位置。",
];
const productStructureLockLines = buildReplacementProductStructureLockLines({
productImageCount: params.productImageCount,
identitySummary: params.scenePlan?.productIdentitySummary,
structureSummary: params.scenePlan?.productStructureSummary,
structuralRisks: params.scenePlan?.productStructuralRisks,
});
const normalizedBasePrompt = String(params.basePrompt || "").trim();
const promptPackInstructionLines =
params.modeType === "detail_replacement" &&
@ -2641,6 +3011,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines,
...scenePlanLines,
...marketplaceBrandRemovalLines,
...productStructureLockLines,
...productAngleLockLines,
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
@ -2670,6 +3041,7 @@ export function buildAgentReplacementPrompt(params: {
...promptPackInstructionLines,
...scenePlanLines,
...marketplaceBrandRemovalLines,
...productStructureLockLines,
...productAngleLockLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"【版式锁层规则】把最后一张参考图当成 PSD 模板:文字、标题、编号、卖点标签、按钮、图标、白底/浅色底、留白、分割线、原有圆角照片区域和原有裁切范围都属于锁定版式层,不允许移动、缩放、重排或重新设计;不要把这些版式边界画成新的白色线框、描边或卡片。",
@ -2785,6 +3157,7 @@ export function buildAgentReplacementPrompt(params: {
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
...productStructureLockLines,
...productAngleLockLines,
params.pairedProductIndex !== undefined
? `请优先使用第 ${params.pairedProductIndex + 1} 张输入商品图作为本张参考图的一对一替换主体。`

View File

@ -1,6 +1,6 @@
import { createLogger } from "@/lib/observability/logger";
import { NextResponse } from "next/server";
import { getRequestErrorMessage } from "@/lib/request-error";
import { getRequestErrorMessage, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.errors");
@ -20,6 +20,14 @@ export function getChatStreamErrorMessage(error: unknown): string {
error instanceof Error ? String(error.message || "").trim() : String(error || "").trim();
const normalized = rawMessage.toLowerCase();
if (isProviderBalanceError(error)) {
return "当前模型服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
}
if (isRateLimitError(error)) {
return "当前模型服务请求过于频繁,请稍后重试或切换模型。";
}
if (
normalized.includes("token quota is not enough") ||
normalized.includes("pre_consume_token_quota_failed") ||
@ -47,6 +55,16 @@ function resolveChatApiFailure(error: unknown) {
const detailMessage = getRequestErrorMessage(error);
const normalized = String(detailMessage || "").toLowerCase();
if (isProviderBalanceError(error)) {
return {
code: "server_error:chat",
message: "模型服务商线路余额或额度不足,请切换模型或联系管理员。",
status: 503,
category: "provider_balance",
detailMessage,
};
}
if (
normalized.includes("payload too large") ||
normalized.includes("request entity too large") ||

View File

@ -4,6 +4,8 @@ import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media";
import { isProviderBalanceError } from "@/lib/request-error";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
const chatApiLogger = createLogger("chat.imageFailover");
@ -57,7 +59,21 @@ export function resolveImageModelRuntimeId(
export function normalizeFailoverErrorMessage(error: unknown): string {
if (!error) return "";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message || String(error);
if (error instanceof Error) {
const record = error as Error & {
status?: unknown;
statusCode?: unknown;
httpStatus?: unknown;
response?: { status?: unknown };
};
const status =
record.status ??
record.statusCode ??
record.httpStatus ??
record.response?.status;
const message = error.message || String(error);
return status ? `[HTTP ${String(status)}] ${message}` : message;
}
try {
return JSON.stringify(error);
} catch {
@ -68,6 +84,7 @@ export function normalizeFailoverErrorMessage(error: unknown): string {
export function shouldCooldownImageFallbackModel(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase();
return (
isProviderBalanceError(errorMessage) ||
msg.includes("model not found") ||
msg.includes("model_not_found") ||
msg.includes("no available channel for model") ||
@ -76,6 +93,13 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean
msg.includes("api key is missing") ||
msg.includes("invalid api key") ||
msg.includes("unauthorized") ||
/(?:^|\D)401(?:\D|$)/.test(msg) ||
msg === "forbidden" ||
msg.includes("access forbidden") ||
msg.includes("access denied") ||
msg.includes("permission denied") ||
/(?:^|\D)403(?:\D|$)/.test(msg) ||
msg.includes("vip98k task failed") ||
msg.includes("cannot convert argument to a bytestring") ||
msg.includes("model maintenance") ||
msg.includes("maintenance") ||
@ -302,9 +326,9 @@ export function buildImageFallbackCandidates(
if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) {
return false;
}
if (isImageFallbackModelCoolingDown(item.model.id)) {
if (isImageFallbackModelCoolingDown(item.model.id) || isImageFallbackModelBalanceCoolingDown(item.model)) {
const cooldown = imageFailoverModelCooldown.get(item.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}`);
chatApiLogger.info(`[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false;
}
return true;
@ -464,9 +488,9 @@ export function getImageCrossProviderCandidates(
}
if (m.model.id === options.currentModelId) return false;
if (options.currentProviderId && m.provider.id === options.currentProviderId) return false;
if (isImageFallbackModelCoolingDown(m.model.id)) {
if (isImageFallbackModelCoolingDown(m.model.id) || isImageFallbackModelBalanceCoolingDown(m.model)) {
const cooldown = imageFailoverModelCooldown.get(m.model.id);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "cooldown"}`);
chatApiLogger.info(`[Chat API] ⏭ Skip image failover model on cooldown: ${m.model.displayName} (${m.provider.type}) - ${cooldown?.reason || "provider balance cooldown"}`);
return false;
}
return true;

View File

@ -1,3 +1,5 @@
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { createLogger } from "@/lib/observability/logger";
import { geolocation } from "@vercel/functions";
import {
@ -179,6 +181,7 @@ import { ChatSDKError } from "@/lib/errors";
import {
getFriendlyRateLimitMessage,
getRequestErrorMessage,
getUserFacingImageGenerationErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
import {
@ -193,10 +196,12 @@ import {
} from "@/lib/ai/runtime/agent-media-markdown";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import { withAuthenticationFailover } from "@/lib/ai/runtime/authentication-failover";
import {
recordAiModelHealthFailure,
recordAiModelHealthSuccess,
shouldFailoverAiModelError,
shouldFailoverVip98kRequestRejection,
} from "@/lib/ai/runtime/model-health";
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
@ -363,6 +368,10 @@ import {
resolveReplacementReferenceGeometry,
resolveReplacementScenePlanningRuntime,
} from "./_lib/detail-replacement";
import {
selectDetailReplacementGenerationInputs,
shouldIncludeDetailReplacementModelImage,
} from "@/lib/ai/detail-replacement-inputs";
import {
normalizeAgentReportForDisplay,
sanitizeAgentOutputSettingsSection,
@ -417,8 +426,6 @@ const chatApiLogger = createLogger("chat.route");
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN =
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) {
const normalizedMarkdown = String(markdown || "").trim();
@ -435,41 +442,13 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
function getPublicImageGenerationErrorText(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
if (!message || INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message)) {
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
return "图片生成失败,请稍后重试";
}
return message;
return publicMessage;
}
function replacementScenePlanShouldIncludeModelImage(scenePlan: any) {
if (!scenePlan) return false;
if (scenePlan.useModelImage === true) return true;
if (
scenePlan.peoplePolicy &&
scenePlan.peoplePolicy !== "none"
) {
return true;
}
if (scenePlan.generationRoute === "people_relation_locked") return true;
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") {
return true;
}
const text = [
scenePlan.modelImageReason,
scenePlan.routeReason,
scenePlan.sceneSummary,
scenePlan.cameraSummary,
scenePlan.placementSummary,
scenePlan.preserveSummary,
scenePlan.styleSummary,
scenePlan.avoidSummary,
scenePlan.generationPrompt,
]
.filter(Boolean)
.join(" ");
return DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN.test(text);
}
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1,
@ -928,6 +907,15 @@ export async function POST(request: Request) {
const json = await request.json();
chatApiLogger.info("[Chat API] Received request body:", JSON.stringify(json, null, 2));
requestBody = postRequestBodySchema.parse(json);
// Agent/Auto Agent has one execution owner: the isolated native ZCode entry.
// This also upgrades existing browser tabs without routing Claw here.
if (requestBody.composerMode === "agent" && requestBody.contextPayload?.source === "canvas" && requestBody.contextPayload.projectId) {
const { POST: nativeAgentPost } = await import("@/app/api/canvas/agent-chat/route");
return nativeAgentPost(new Request(new URL("/api/canvas/agent-chat", request.url), {
method: "POST", headers: request.headers, body: JSON.stringify(json),
}));
}
} catch (error) {
chatApiLogger.error("[Chat API] Schema validation failed:", error);
return new ChatSDKError("bad_request:api").toResponse();
@ -1222,6 +1210,14 @@ export async function POST(request: Request) {
}
}
if (shouldRunAgentConversation && preferredAgentModelId && !shouldKeepSelectedModel) {
const preferred = await getAiModelById({ id: preferredAgentModelId }).catch(() => null);
if (!preferred?.isEnabled || preferred.healthStatus === "auto_disabled") {
preferredAgentModelId = undefined;
configuredAgentConversationModelId = undefined;
}
}
if (shouldRunAgentConversation && !preferredAgentModelId) {
const configuredAgentModel = await getAgentAiModelWithProvider().catch((agentModelError) => {
chatApiLogger.warn("[Chat API] Failed to load configured agent model:", agentModelError);
@ -1342,6 +1338,7 @@ export async function POST(request: Request) {
}
const allowAgentVideoGeneration =
(contextPayload?.source === "canvas" && inferVideoTaskRequirements(requestTextContent).expectsVideo) ||
Boolean(requestBody.videoParams) ||
isVideoProviderType(requestedModelProviderType, requestedModelConfig || undefined);
let smartAutoWorkflowDecision:
@ -5723,12 +5720,15 @@ export async function POST(request: Request) {
sourceImageUrls: string[];
streamLabel: string;
promptForPersistence?: string | null;
isFallback?: boolean;
}) => {
const persistedImageSize =
String(params.imageSizeLabel || "").trim() || resolvedImageSize;
const assistantText = prependImageModelBadge(
[
`<!-- AGENT_REPLACEMENT_CARD_ID:${String(params.reference.id || "").trim()} -->`,
`<!-- AGENT_REPLACEMENT_RUN_ID:${message.id} -->`,
`<!-- AGENT_REPLACEMENT_STATUS:${params.isFallback ? "failed" : "completed"} -->`,
`### ${params.reference.label}`,
String(params.bodyText || "").trim(),
buildAgentToolImagesMarkdown([
@ -5764,26 +5764,28 @@ export async function POST(request: Request) {
],
});
await saveGeneratedImages(
assistantText,
session.user.id,
id,
assistantMessageId,
params.promptForPersistence || baseReplacementPrompt,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: params.sourceImageUrls,
relationType: "edited_from",
sourceType: "detail_replacement",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
requestedImageCount: 1,
aspectRatio: resolvedAspectRatio,
imageSize: persistedImageSize,
},
);
if (!params.isFallback) {
await saveGeneratedImages(
assistantText,
session.user.id,
id,
assistantMessageId,
params.promptForPersistence || baseReplacementPrompt,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: params.sourceImageUrls,
relationType: "edited_from",
sourceType: "detail_replacement",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: params.promptForPersistence || baseReplacementPrompt,
requestedImageCount: 1,
aspectRatio: resolvedAspectRatio,
imageSize: persistedImageSize,
},
);
}
} catch (persistError) {
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
chatId: id,
@ -5933,20 +5935,18 @@ export async function POST(request: Request) {
const shouldIncludeModelImage =
detailReplacementStrategy !== "product_only" &&
Boolean(modelInputImageUrl) &&
(replacementScenePlanShouldIncludeModelImage(
(shouldIncludeDetailReplacementModelImage(
replacementExecutionPlan.scenePlan,
) ||
selectedProductContextImageUrls.length === 0);
const sourceImageUrls = Array.from(
new Set([
...selectedProductContextImageUrls,
...(shouldIncludeModelImage && modelInputImageUrl
? [modelInputImageUrl]
: []),
...rerunReferenceImageUrls,
reference.imageUrl,
]),
);
const generationInputs = selectDetailReplacementGenerationInputs({
productImageUrls: selectedProductContextImageUrls,
modelImageUrl: modelInputImageUrl,
includeModelImage: shouldIncludeModelImage,
rerunReferenceImageUrls,
referenceImageUrl: reference.imageUrl,
});
const sourceImageUrls = generationInputs.imageUrls;
chatApiLogger.info("[Chat API] Direct detail replacement image generation started:", {
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
@ -5954,10 +5954,10 @@ export async function POST(request: Request) {
pageKind: replacementExecutionPlan.scenePlan.pageKind,
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
selectedProductImages: selectedProductContextImageUrls.length,
selectedProductImages: generationInputs.productImageUrls.length,
sourceProductImages: productInputImageUrls.length,
includeModelImage: shouldIncludeModelImage,
rerunReferenceImages: rerunReferenceImageUrls.length,
includeModelImage: Boolean(generationInputs.modelImageUrl),
rerunReferenceImages: generationInputs.rerunImageUrls.length,
sourceImageCount: sourceImageUrls.length,
whiteLayout: replacementExecutionPlan.scenePlan.whiteBackgroundLikely,
copyCoupled: replacementExecutionPlan.scenePlan.copyImageCoupled,
@ -5972,10 +5972,10 @@ export async function POST(request: Request) {
referenceLabel: reference.label,
referenceIndex,
totalReferences: replacementReferences.length,
hasModelImage: shouldIncludeModelImage,
productImageCount: selectedProductContextImageUrls.length,
hasModelImage: Boolean(generationInputs.modelImageUrl),
productImageCount: generationInputs.productImageUrls.length,
originalProductImageCount: productInputImageUrls.length,
rerunReferenceCount: rerunReferenceImageUrls.length,
rerunReferenceCount: generationInputs.rerunImageUrls.length,
templateReferenceCount: 0,
scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry,
@ -6007,6 +6007,10 @@ export async function POST(request: Request) {
imageSize: resolvedImageSize,
imageCount: 1,
referenceImages: sourceImageUrls,
promptExtend: false,
providerImageParams: {
inputImageRoleByUrl: generationInputs.inputImageRoleByUrl,
},
debugLabel: "agent-detail-replacement",
debugMeta: {
referenceLabel: reference.label,
@ -6054,6 +6058,9 @@ export async function POST(request: Request) {
referenceImageUrl: reference.imageUrl,
referenceGeometry,
scenePlan: replacementExecutionPlan.scenePlan,
preserveReferenceOutsideSubject:
detailReplacementStrategy === "product_only",
preserveReferenceText: true,
uploadPrefix: "agent-detail-replacement",
});
finalImage = {
@ -6136,8 +6143,8 @@ export async function POST(request: Request) {
modelDisplayName: null,
sourceImageUrls: referenceSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
isFallback: true,
});
successCount += 1;
}
} catch (replacementPlanError) {
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
@ -6160,8 +6167,8 @@ export async function POST(request: Request) {
modelDisplayName: null,
sourceImageUrls: fallbackSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
isFallback: true,
});
successCount += 1;
}
});
} finally {
@ -6489,9 +6496,20 @@ export async function POST(request: Request) {
...(selectedAgentPreset.defaultParams || {}),
...(replacementSlot?.defaultParams || {}),
} as Record<string, any>;
const resolvedModelRef =
String(selectedChatModel || "").trim() &&
String(selectedChatModel || "").trim() !== "auto"
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
(error) => {
chatApiLogger.warn("[Chat API] Failed to load configured vision models for replacement analysis:", error);
return undefined;
},
);
const resolvedModelRef = isDetailReplacementPreset
? await resolveDirectReplacementModelRef({
selectedChatModel,
finalModelId: presetModelId,
models: replacementVisionModels,
})
: String(selectedChatModel || "").trim() &&
String(selectedChatModel || "").trim() !== "auto"
? String(selectedChatModel || "").trim()
: presetModelId;
const configuredReplacementAspectRatio =
@ -6513,12 +6531,6 @@ export async function POST(request: Request) {
configuredAspectRatio: configuredReplacementAspectRatio,
});
}
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
(error) => {
chatApiLogger.warn("[Chat API] Failed to load configured vision models for replacement analysis:", error);
return undefined;
},
);
const replacementScenePlanningRuntime =
await resolveReplacementScenePlanningRuntime({
models: replacementVisionModels,
@ -6816,24 +6828,36 @@ export async function POST(request: Request) {
!(isDetailReplacementPreset &&
detailReplacementStrategy === "product_only") &&
Boolean(modelInputImageUrl) &&
(replacementScenePlanShouldIncludeModelImage(
(shouldIncludeDetailReplacementModelImage(
replacementExecutionPlan.scenePlan,
) ||
(isDetailReplacementPreset &&
selectedProductContextImageUrls.length === 0));
const sourceImageUrls = Array.from(
new Set(
[
...selectedProductContextImageUrls,
...(shouldIncludeModelImage && modelInputImageUrl
? [modelInputImageUrl]
: []),
...replacementTemplateReferenceImageUrls,
...rerunReferenceImageUrls,
reference.imageUrl,
].filter(Boolean),
),
);
const detailGenerationInputs = isDetailReplacementPreset
? selectDetailReplacementGenerationInputs({
productImageUrls: selectedProductContextImageUrls,
modelImageUrl: modelInputImageUrl,
includeModelImage: shouldIncludeModelImage,
templateReferenceImageUrls: replacementTemplateReferenceImageUrls,
rerunReferenceImageUrls,
referenceImageUrl: reference.imageUrl,
})
: null;
const sourceImageUrls = detailGenerationInputs
? detailGenerationInputs.imageUrls
: Array.from(
new Set(
[
...selectedProductContextImageUrls,
...(shouldIncludeModelImage && modelInputImageUrl
? [modelInputImageUrl]
: []),
...replacementTemplateReferenceImageUrls,
...rerunReferenceImageUrls,
reference.imageUrl,
].filter(Boolean),
),
);
const replacementPrompt = buildAgentReplacementPrompt({
basePrompt:
baseReplacementPrompt ||
@ -6850,11 +6874,19 @@ export async function POST(request: Request) {
referenceLabel: reference.label,
referenceIndex,
totalReferences: replacementReferences.length,
hasModelImage: shouldIncludeModelImage,
productImageCount: selectedProductContextImageUrls.length,
hasModelImage: detailGenerationInputs
? Boolean(detailGenerationInputs.modelImageUrl)
: shouldIncludeModelImage,
productImageCount: detailGenerationInputs
? detailGenerationInputs.productImageUrls.length
: selectedProductContextImageUrls.length,
originalProductImageCount: productInputImageUrls.length,
rerunReferenceCount: rerunReferenceImageUrls.length,
templateReferenceCount: replacementTemplateReferenceImageUrls.length,
rerunReferenceCount: detailGenerationInputs
? detailGenerationInputs.rerunImageUrls.length
: rerunReferenceImageUrls.length,
templateReferenceCount: detailGenerationInputs
? detailGenerationInputs.templateImageUrls.length
: replacementTemplateReferenceImageUrls.length,
pairedProductIndex,
scenePlan: replacementExecutionPlan.scenePlan,
referenceGeometry,
@ -6875,11 +6907,20 @@ export async function POST(request: Request) {
sourceImageCount: sourceImageUrls.length,
generationAspectRatio,
imageOrder: {
productImages: selectedProductContextImageUrls.length,
productImages:
detailGenerationInputs !== null
? detailGenerationInputs.productImageUrls.length
: selectedProductContextImageUrls.length,
originalProductImages: productInputImageUrls.length,
hasModelImage: shouldIncludeModelImage,
templateReferenceImages: replacementTemplateReferenceImageUrls.length,
rerunReferenceImages: rerunReferenceImageUrls.length,
hasModelImage: detailGenerationInputs
? Boolean(detailGenerationInputs.modelImageUrl)
: shouldIncludeModelImage,
templateReferenceImages: detailGenerationInputs
? detailGenerationInputs.templateImageUrls.length
: replacementTemplateReferenceImageUrls.length,
rerunReferenceImages: detailGenerationInputs
? detailGenerationInputs.rerunImageUrls.length
: rerunReferenceImageUrls.length,
finalReferenceImageIndex: sourceImageUrls.length,
},
});
@ -6897,6 +6938,17 @@ export async function POST(request: Request) {
imageSize: resolvedImageSize,
imageCount: 1,
referenceImages: sourceImageUrls,
promptExtend: isDetailReplacementPreset
? false
: typeof mergedImageParams.promptExtend === "boolean"
? mergedImageParams.promptExtend
: undefined,
providerImageParams: detailGenerationInputs
? {
inputImageRoleByUrl:
detailGenerationInputs.inputImageRoleByUrl,
}
: undefined,
debugLabel: `agent-${replacementSourceType}`,
debugMeta: {
referenceLabel: reference.label,
@ -6943,6 +6995,10 @@ export async function POST(request: Request) {
referenceImageUrl: reference.imageUrl,
referenceGeometry,
scenePlan: replacementExecutionPlan.scenePlan,
preserveReferenceOutsideSubject:
isDetailReplacementPreset &&
detailReplacementStrategy === "product_only",
preserveReferenceText: isDetailReplacementPreset,
uploadPrefix: isDetailReplacementPreset
? "agent-detail-replacement"
: "agent-sku-replacement",
@ -8830,7 +8886,7 @@ export async function POST(request: Request) {
.filter(Boolean)
.join("\n\n");
const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
let activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(
resolvedRuntimeModelLookupId,
).catch(() => null);
if (shouldRunAgentConversation && activeRuntimeModelDescriptor) {
@ -9635,6 +9691,11 @@ export async function POST(request: Request) {
alibabaModelConfig?.fallbackModelId2,
].filter(Boolean) as string[];
const allModels = await getEnabledAiModelsWithProvider();
const currentModelEntry = resolveModelEntryByRuntimeModelId(
allModels,
actualModelId,
finalModelId,
);
const fallbackCandidates = buildImageFallbackCandidates(allModels, {
currentProviderId: alibabaModelConfig?.providerId,
currentModelId: alibabaModelConfig?.id,
@ -9880,6 +9941,7 @@ export async function POST(request: Request) {
let imageMarkdown = "";
let usedModelId = actualModelId;
const generationStartedAt = Date.now();
// 🆕 图片生成真正开始时发出标记(用于前端显示占位图)
writeAssistantTextChunk(
@ -9906,14 +9968,43 @@ export async function POST(request: Request) {
} catch (primaryError) {
chatApiLogger.error("[Chat API] Alibaba primary model failed:", primaryError);
if (!shouldFailoverAiModelError(primaryError)) {
await recordAiModelHealthFailure({
model: currentModelEntry?.model || alibabaModelConfig,
provider: currentModelEntry?.provider,
error: primaryError,
context: "chat_image_primary",
elapsedMs: Date.now() - generationStartedAt,
metadata: {
runtimeModelId: actualModelId,
finalModelId,
providerType: (languageModel as any).provider,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
imageSize: effectiveImageGenerationParams.imageSize,
imageCount:
effectiveImageGenerationParams.imageCount ||
defaultImageQualityParams.imageCount ||
1,
},
}).catch((healthError) => {
chatApiLogger.warn("[Chat API] Record image primary health failure failed:", healthError);
});
if (
!shouldFailoverAiModelError(primaryError) &&
!shouldFailoverVip98kRequestRejection(
primaryError,
(languageModel as any).provider,
)
) {
chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error.");
throw primaryError;
}
let fallbackSuccess = false;
const triedFallbackModelIds = new Set<string>();
for (const candidate of fallbackCandidates) {
try {
triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`);
const fallbackResult = await generateImageWithFailoverCandidate({
candidate,
@ -9938,6 +10029,22 @@ export async function POST(request: Request) {
if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
}
await recordAiModelHealthFailure({
model: candidate.model,
provider: candidate.provider,
error: fallbackError,
context: "chat_image_fallback",
elapsedMs: Date.now() - generationStartedAt,
metadata: {
primaryModelId: actualModelId,
fallbackModelId: candidate.model.modelId,
providerType: candidate.provider.type,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
imageSize: effectiveImageGenerationParams.imageSize,
},
}).catch((healthError) => {
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
});
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
}
}
@ -9966,10 +10073,11 @@ export async function POST(request: Request) {
currentModelKey: actualModelId,
currentProviderId: alibabaModelConfig?.providerId,
providerPriority,
});
}).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || "")));
for (const candidate of crossProviderCandidates) {
try {
triedFallbackModelIds.add(String(candidate.model.id || ""));
chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`);
if (
candidate.provider.type === "fal-image" &&
@ -10053,6 +10161,22 @@ export async function POST(request: Request) {
if (shouldCooldownImageFallbackModel(crossErrorMessage)) {
setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage);
}
await recordAiModelHealthFailure({
model: candidate.model,
provider: candidate.provider,
error: crossError,
context: "chat_image_cross_fallback",
elapsedMs: Date.now() - generationStartedAt,
metadata: {
primaryModelId: actualModelId,
fallbackModelId: candidate.model.modelId,
providerType: candidate.provider.type,
aspectRatio: effectiveImageGenerationParams.aspectRatio,
imageSize: effectiveImageGenerationParams.imageSize,
},
}).catch((healthError) => {
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
});
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
}
}
@ -10066,6 +10190,19 @@ export async function POST(request: Request) {
chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length);
chatApiLogger.info("[Chat API] Alibaba used model:", usedModelId);
const usedModelEntry = resolveModelEntryByRuntimeModelId(
allModels,
usedModelId,
usedModelId === actualModelId ? finalModelId : undefined,
);
await recordAiModelHealthSuccess({
model:
usedModelEntry?.model ||
(usedModelId === actualModelId ? alibabaModelConfig : null),
}).catch((healthError) => {
chatApiLogger.warn("[Chat API] Record image health success failed:", healthError);
});
const usedModelDisplayName =
resolveModelDisplayNameByModelId(allModels, usedModelId) ||
alibabaModelConfig?.displayName ||
@ -11106,7 +11243,7 @@ export async function POST(request: Request) {
requestHeaders: request.headers,
requestUrl: request.url,
defaultModelId: finalModelId,
destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent),
authorizeDestructive: (nodeIds) => isExplicitDestructiveIntent(requestTextContent) && nodeIds.length > 0 && nodeIds.every((nodeId) => selectedCanvasImageNodeIds.includes(nodeId)),
callbacks: {
onClientCommand: (command, capability) => {
writeCanvasCapabilityCommandChunk(
@ -12135,8 +12272,70 @@ export async function POST(request: Request) {
const shouldDisableSmoothStream =
resolvedModelSupportsImageGen || isVolcanoVideoModel;
let activeConversationModel = currentModelConfig;
let activeConversationProvider = currentProviderConfig;
const recordedAuthFailures = new Set<string>();
const recordConversationAuthFailure = async (error: unknown) => {
const key = activeConversationModel?.id;
if (!key || recordedAuthFailures.has(key)) return;
recordedAuthFailures.add(key);
recordProviderHealthFailure({
providerKey: activeConversationProvider?.id,
providerType: activeConversationProvider?.type,
error,
});
await recordAiModelHealthFailure({
model: activeConversationModel,
provider: activeConversationProvider,
error,
context: "canvas_agent_conversation_auth",
}).catch((healthError) => chatApiLogger.warn("[Chat API] Could not persist model authentication failure:", healthError));
};
const needsVisionFallback = convertedMessages.some((item: any) =>
Array.isArray(item.content) && item.content.some((part: any) =>
part.type === "image" || (part.type === "file" && String(part.mediaType || "").startsWith("image/"))
)
);
const streamModel = shouldRunAgentConversation && !shouldKeepSelectedModel &&
currentModelConfig?.excludeFromFailover !== true &&
!resolvedModelSupportsImageGen && !isVolcanoVideoModel
? withAuthenticationFailover({
primary: languageModel as any,
onAuthenticationFailure: recordConversationAuthFailure,
resolveFallback: async () => {
const enabled = await getEnabledAiModelsWithProvider(false);
const preferredIds = [currentModelConfig?.fallbackModelId1, currentModelConfig?.fallbackModelId2];
const candidates = enabled.filter(({ model, provider }) =>
model.id !== activeConversationModel?.id && model.isEnabled && provider.isEnabled &&
!model.excludeFromFailover && !model.isSubModel && model.healthStatus !== "auto_disabled" &&
!isImageGenerationModelType(provider.type, model) && !isVideoProviderType(provider.type, model) &&
(!needsVisionFallback || model.supportsVision) &&
(isAiModelAgentAuto(model) || preferredIds.includes(model.id)) &&
!shouldAvoidProvider(getProviderHealthSnapshot(provider.id))
).sort((a, b) => {
const rank = (id: string, isDefault: boolean) => {
const index = preferredIds.indexOf(id);
return index >= 0 ? index : isDefault ? 2 : 3;
};
return rank(a.model.id, a.model.isDefault) - rank(b.model.id, b.model.isDefault);
});
for (const candidate of candidates) {
const fallback = await getLanguageModelById(candidate.model.id);
if (!fallback || typeof fallback === "string") continue;
activeConversationModel = candidate.model;
activeConversationProvider = candidate.provider;
finalModelId = candidate.model.id;
actualModelId = candidate.model.modelId;
activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor(candidate.model.id).catch(() => null);
return fallback as any;
}
return null;
},
})
: languageModel;
const result = streamText({
model: languageModel,
model: streamModel,
maxRetries: shouldRunAgentConversation ? 0 : undefined,
system: effectiveConversationSystemPrompt,
messages: convertedMessages,
stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),

View File

@ -13,6 +13,10 @@ import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries";
import {
IMAGE_UPLOAD_MAX_BYTES,
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
} from "@/lib/image-upload-limits";
import { createLogger } from "@/lib/observability/logger";
import {
buildS3PublicUrl,
@ -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) {
return OPENCLAW_CHAT_VIDEO_MAX_BYTES;
}
@ -281,9 +288,14 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
const maxBytes = getPurposeMaxBytes(payload.purpose, contentType);
if (payload.size > maxBytes) {
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 }
);
}
@ -390,8 +402,15 @@ export async function POST(request: Request) {
if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 });
}
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: "文件超过允许的上传大小",
},
{ status: 413 }
);
}
const expectedPartSize = computePartSize(payload.size);
if (payload.partSize !== expectedPartSize) {
@ -505,8 +524,15 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) {
return NextResponse.json(
{
error: contentType.startsWith("image/")
? IMAGE_UPLOAD_TOO_LARGE_MESSAGE
: "文件超过允许的上传大小",
},
{ status: 413 }
);
}
if (payload.uploadId) {

View File

@ -1,5 +1,9 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
isImageUploadTooLarge,
} from "@/lib/image-upload-limits";
import { auth } from "@/app/(auth)/auth";
import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images";
@ -260,6 +264,12 @@ export async function POST(request: Request) {
const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml";
if (contentType.startsWith("image/") && isImageUploadTooLarge(file.size)) {
return NextResponse.json(
{ error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE },
{ status: 413 }
);
}
if (
isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))

View File

@ -102,8 +102,8 @@ export async function GET(request: Request) {
},
{
id: "local-e2e-video-model",
modelId: "kling-3-local",
displayName: "可灵 3.0",
modelId: "veo-3.1-fast",
displayName: "Veo 3.1 Fast(本地布局测试)",
avatarUrl: null,
description: "本地视频画布测试视频模型",
supportsVision: true,
@ -111,18 +111,18 @@ export async function GET(request: Request) {
isMultimodal: true,
isDefault: false,
providerName: "Local E2E",
providerType: "local",
providerType: "vip98k",
isSubModel: false,
parentModelId: null,
capabilities: { video: true },
capabilities: { kind: "video", supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"], supportedRatios:["16:9"], supportedResolutions:["720p","1080p"], durationOptions:[8], maxReferenceImages:3 },
videoToolCapability: {
supported: true,
durations: [5, 10],
durations: [8],
resolutions: ["720p", "1080p"],
},
imageGenConfig: {
creditCostPerVideo: 20,
videoDurationOptions: [5, 10],
videoDurationOptions: [8],
},
sortOrder: 1,
selectable: true,

View File

@ -3,10 +3,5 @@ export default function FlowBoardLayout({
}: {
children: React.ReactNode;
}) {
return (
<>
<link href="/vendor/drawnix.css" rel="stylesheet" />
{children}
</>
);
return <>{children}</>;
}

View File

@ -1,5 +1,6 @@
import { Suspense } from "react";
import { ChatShell } from "@/components/chat-shell";
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
import { DataStreamProvider } from "@/components/data-stream-provider";
import { auth } from "../(auth)/auth";
import {
@ -8,10 +9,14 @@ import {
getTutorialVideos,
} from "@/lib/db/queries";
export const dynamic = "force-dynamic";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh" />}>
<Suspense
fallback={<CanvasLogoLoadingView label="正在加载页面..." />}
>
<SidebarWrapper>{children}</SidebarWrapper>
</Suspense>
</DataStreamProvider>

View File

@ -1,79 +1,75 @@
import { randomUUID } from "node:crypto";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { VideoCanvasHome, type VideoCanvasHomeProject } from "@/components/canvas/VideoCanvasHome";
import { db } from "@/lib/db";
import { getSiteSettings } from "@/lib/db/queries";
import { canvasProject } from "@/lib/db/schema";
type VideoCanvasRedirectPageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
};
type Props = { searchParams?: Promise<Record<string, string | string[] | undefined>> };
function getSearchParam(
params: Record<string, string | string[] | undefined>,
key: string
) {
const value = params[key];
function first(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function buildVideoCanvasUrl(
canvasId: string,
params: Record<string, string | string[] | undefined>
) {
const query = new URLSearchParams();
query.set("mode", "video");
query.set("workspaceType", "video");
for (const [key, value] of Object.entries(params)) {
if (
key === "new" ||
key === "mode" ||
key === "workspaceType" ||
value === undefined
) {
continue;
}
if (Array.isArray(value)) {
value.forEach((item) => query.append(key, item));
continue;
}
query.set(key, value);
function canvasHref(id: string, incoming: Record<string, string | string[] | undefined>) {
const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
for (const [key, value] of Object.entries(incoming)) {
if (["new", "mode", "workspaceType"].includes(key) || value === undefined) continue;
for (const item of Array.isArray(value) ? value : [value]) params.append(key, item);
}
return `/canvas/${canvasId}?${query.toString()}`;
return `/canvas/${id}?${params.toString()}`;
}
async function getLatestVideoCanvasId(userId: string) {
const [latestProject] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, userId),
eq(canvasProject.workspaceType, "video")
)
)
.orderBy(desc(canvasProject.updatedAt))
.limit(1);
return latestProject?.id || null;
function projectPreview(thumbnail: string | null, canvasData: unknown) {
const nodes = canvasData && typeof canvasData === "object" && "nodes" in canvasData
? (canvasData as { nodes?: unknown }).nodes : null;
const candidates = [thumbnail];
if (Array.isArray(nodes)) for (const node of [...nodes].reverse()) {
if (!node || typeof node !== "object") continue;
const item = node as { inputType?: string; draftValues?: Record<string, unknown> };
const draft = item.draftValues || {};
candidates.push(
String(draft.poster || draft.previewImageUrl || draft.thumbnailUrl || draft.resultImage || (item.inputType === "image" ? draft.mediaUrl || (Array.isArray(draft.url) ? draft.url[0] : "") : "") || ""),
);
}
return candidates.find(value => typeof value === "string" && (value.startsWith("https://") || value.startsWith("/"))) || null;
}
export default async function VideoCanvasRedirectPage({
searchParams,
}: VideoCanvasRedirectPageProps) {
const params = (await searchParams) || {};
const shouldCreateNew = getSearchParam(params, "new") === "1";
if (shouldCreateNew) {
redirect(buildVideoCanvasUrl(randomUUID(), params));
}
export default async function VideoCanvasHomePage({ searchParams }: Props) {
const session = await auth();
const latestCanvasId = session?.user?.id
? await getLatestVideoCanvasId(session.user.id).catch(() => null)
: null;
if (!session?.user?.id) redirect("/login?callbackUrl=%2Fvideo-canvas");
redirect(buildVideoCanvasUrl(latestCanvasId || randomUUID(), params));
const params = (await searchParams) || {};
if (first(params.new) === "1") {
const [created] = await db.insert(canvasProject).values({
userId: session.user.id,
title: "未命名视频画布",
workspaceType: "video",
canvasData: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
}).returning({ id: canvasProject.id });
redirect(canvasHref(created.id, params));
}
const [rows, siteSettings] = await Promise.all([db.select({
id: canvasProject.id,
title: canvasProject.title,
thumbnail: canvasProject.thumbnail,
canvasData: canvasProject.canvasData,
createdAt: canvasProject.createdAt,
updatedAt: canvasProject.updatedAt,
}).from(canvasProject).where(and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "video"),
)).orderBy(desc(canvasProject.updatedAt)).limit(25), getSiteSettings()]);
const projects: VideoCanvasHomeProject[] = rows.slice(0, 24).map(row => ({
id: row.id,
title: row.title,
thumbnail: projectPreview(row.thumbnail, row.canvasData),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}));
return <VideoCanvasHome initialHasMore={rows.length > 24} initialProjects={projects} siteLogo={siteSettings.siteLogo} siteLogoDark={siteSettings.siteLogoDark} siteName={siteSettings.siteName} userName={session.user.name || ""} />;
}

View File

@ -94,6 +94,8 @@ export async function POST(request: Request) {
providerId,
modelId: preset.modelId,
...values,
isEnabled: true,
isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,

View File

@ -76,8 +76,6 @@ export async function POST(request: Request) {
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,

View 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 });
}
}

View File

@ -1,385 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import {
createMediaAssetRelation,
getMediaAssetsByUserAndUrls,
getPointsSystemConfig,
getSystemSetting,
upsertMediaAsset,
} from "@/lib/db/queries";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger";
import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
const logger = createLogger("angle-edit-api");
// 角度编辑API配置类型
type AngleEditConfig = {
enabled?: boolean;
falApiKey?: string;
loraScale?: number;
inferenceSteps?: number;
guidanceScale?: number;
maxImageSize?: number;
timeout?: number;
};
// 设置API路由最大执行时间为3分钟(180秒)
import { executeAngleEditPOST } from "@/lib/canvas/operations/angle-edit";
export const runtime = "nodejs";
export const maxDuration = 180;
/**
* 压缩图片生成缩略图
*/
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
const sharp = await import("sharp");
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 80 })
.toBuffer();
logger.debug("thumbnail_created", {
originalBytes: buffer.length,
thumbnailBytes: thumbnailBuffer.length,
});
return thumbnailBuffer;
} catch (error) {
logger.warn("thumbnail_creation_failed", error);
return buffer;
}
}
/**
* 将 base64 图片上传到 S3
*/
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
try {
const buffer = Buffer.from(base64Data, "base64");
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
logger.debug("source_image_prepared", { originalBytes: buffer.length });
const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
logger.debug("storage_upload_started", {
hasOriginal: originalArrayBuffer.byteLength > 0,
hasThumbnail: thumbnailArrayBuffer.byteLength > 0,
prefix,
});
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
logger.info("storage_upload_completed", {
originalUrl: originalResult.url,
thumbnailUrl: thumbnailResult.url,
});
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
logger.error("storage_upload_failed", error);
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
}
}
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
// 1. 验证用户登录
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
logger.info("request_received", { authenticated: true });
// 2. 从数据库获取配置
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
const config = (angleEditConfig?.value as AngleEditConfig) || {};
// 验证功能是否启用
if (!config.enabled) {
return NextResponse.json(
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
{ status: 400 }
);
}
// 验证 API Key
if (!config.falApiKey) {
return NextResponse.json(
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
{ status: 500 }
);
}
logger.debug("config_loaded", {
enabled: Boolean(config.enabled),
hasApiKey: Boolean(config.falApiKey),
loraScale: config.loraScale || 1,
});
// 3. 解析请求
const body = await request.json();
const {
image_url,
image_base64,
// 数值参数 (官方 API 格式)
horizontal_angle, // 0-360 度
vertical_angle, // -30 到 90 度
zoom, // 0-10
lora_scale,
} = body;
if (!image_url && !image_base64) {
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
}
// 使用请求中的参数或配置中的默认值
const finalLoraScale = lora_scale ?? config.loraScale ?? 1;
const finalHorizontalAngle = horizontal_angle ?? 0;
const finalVerticalAngle = vertical_angle ?? 0;
const finalZoom = zoom ?? 5;
logger.info("request_parameters_normalized", {
hasBase64Input: Boolean(image_base64),
hasUrlInput: Boolean(image_url),
horizontalAngle: finalHorizontalAngle,
loraScale: finalLoraScale,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
});
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"),
reason: "image_angle_edit",
modelId: "qwen-image-edit-2511-multiple-angles",
note: "多角度编辑消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
// 4. 准备图片输入
let imageInput: string;
if (image_url) {
imageInput = image_url;
} else {
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
imageInput = `data:${mimeType};base64,${image_base64}`;
}
// 5. 动态导入 fal.ai SDK 并调用官方多角度 API
logger.info("provider_call_started", {
modelId: "fal-ai/qwen-image-edit-2511-multiple-angles",
});
const fal = await import("@fal-ai/serverless-client");
fal.config({
credentials: config.falApiKey,
});
// 使用官方多角度 API 端点
// 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles
const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
input: {
image_urls: [imageInput], // 官方使用 image_urls (数组)
horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360
vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90
zoom: finalZoom, // 缩放 0-10
lora_scale: finalLoraScale, // LoRA 强度
num_inference_steps: config.inferenceSteps || 28,
guidance_scale: config.guidanceScale || 4.5, // 官方默认 4.5
enable_safety_checker: true,
output_format: "png",
},
logs: true,
onQueueUpdate: (update) => {
if (update.status === "IN_PROGRESS") {
logger.debug("provider_progress", { status: update.status });
}
},
});
// 6. 处理结果
const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string };
const outputImage = apiResult.images?.[0];
if (!outputImage?.url) {
throw new Error("API 未返回有效图片");
}
const outputImageUrl = outputImage.url;
logger.info("provider_call_completed", {
hasGeneratedPrompt: Boolean(apiResult.prompt),
imageCount: apiResult.images?.length ?? 0,
outputImageUrl,
});
// 7. 下载图片并上传到我们的 S3
const imageResponse = await fetch(outputImageUrl);
if (!imageResponse.ok) {
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = imageBuffer.toString("base64");
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
logger.info("result_image_persisted", {
originalUrl: s3Result.original,
thumbnailUrl: s3Result.thumbnail,
});
// 8. 保存到数据库
const processingTime = (Date.now() - startTime) / 1000;
const [savedImage] = await db
.insert(generatedImage)
.values({
userId: session.user.id,
thumbnailUrl: s3Result.thumbnail,
originalUrl: s3Result.original,
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
modelId: "qwen-image-edit-2511-multiple-angles",
createdAt: new Date(),
})
.returning();
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `多角度编辑消耗积分(${savedImage.id})`,
});
} catch (pointsError) {
logger.error("points_consume_failed", pointsError, {
imageId: savedImage.id,
userId: session.user.id,
});
}
}
try {
const sourceAssets =
typeof image_url === "string" && image_url.trim()
? await getMediaAssetsByUserAndUrls({
userId: session.user.id,
urls: [image_url],
})
: [];
const media = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: s3Result.original,
previewUrl: s3Result.original,
thumbnailUrl: s3Result.thumbnail,
displayName: `角度编辑 ${finalHorizontalAngle}/${finalVerticalAngle}`.slice(0, 80),
prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
modelId: "qwen-image-edit-2511-multiple-angles",
sourceTable: "GeneratedImage",
sourceRowId: savedImage.id,
primaryParentAssetId: sourceAssets[0]?.id ?? null,
metadata: {
horizontalAngle: finalHorizontalAngle,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
},
});
if (media) {
for (const sourceAsset of sourceAssets) {
await createMediaAssetRelation({
userId: session.user.id,
fromAssetId: sourceAsset.id,
toAssetId: media.id,
relationType: "edited_from",
metadata: {
horizontalAngle: finalHorizontalAngle,
verticalAngle: finalVerticalAngle,
zoom: finalZoom,
},
});
}
}
} catch (assetError) {
logger.warn("media_asset_registration_failed", assetError);
}
logger.info("request_completed", {
processingSeconds: Number(processingTime.toFixed(2)),
});
// 9. 返回结果
return NextResponse.json({
success: true,
image_url: s3Result.original,
thumbnail_url: s3Result.thumbnail,
image_base64: imageBase64Result,
processing_time: processingTime,
angles: {
horizontal_angle: finalHorizontalAngle,
vertical_angle: finalVerticalAngle,
zoom: finalZoom,
},
prompt: apiResult.prompt,
});
} catch (error) {
logger.error("request_failed", error);
// 输出详细的错误信息
if (error && typeof error === "object" && "status" in error) {
const apiError = error as { status?: number; body?: unknown; message?: string };
logger.warn("provider_error_summary", {
bodyType: typeof apiError.body,
hasBody: apiError.body != null,
message: apiError.message,
status: apiError.status,
});
if (apiError.status === 403) {
return NextResponse.json(
{
error: "API 访问被拒绝 (403 Forbidden)。请检查 API Key 是否正确,以及是否有权限访问该模型。",
details: apiError.body,
},
{ status: 403 }
);
}
}
return NextResponse.json(
{
error: error instanceof Error ? error.message : "角度编辑处理失败",
},
{ status: 500 }
);
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
return executeAngleEditPOST(request, session.user.id);
}

View File

@ -14,6 +14,7 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"worldlabs.ai",
"s3.1080p.hk",
"img.yydd.ai",
"scqny.jiansun.vip",
"blob.vercel-storage.com",
"aliyuncs.com",
"amazonaws.com",

View 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" } },
);
}

View File

@ -3,6 +3,7 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
import { eq, and } from 'drizzle-orm';
import {
buildStaleCanvasSaveConflictPayload,
@ -102,7 +103,7 @@ export async function GET(
// 更新画布(支持 upsert:如果不存在则创建)
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
@ -116,29 +117,34 @@ export async function PUT(
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 },
);
}
const { title, description, thumbnail, canvasData } = body;
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const { title, description, canvasData } = body;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const baseCanvasRevision =
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
? resolveWorkspaceType(body.workspaceType)
: undefined;
// 先检查画布是否存在
const [existingProject] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id)
return await db.transaction(async (tx) => {
const [existingProject] = await tx
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
),
)
);
if (existingProject) {
.for("update");
if (
existingProject &&
canvasData !== undefined &&
baseCanvasRevision &&
isStaleCanvasSaveBaseRevision({
@ -149,101 +155,66 @@ export async function PUT(
) {
return NextResponse.json(
buildStaleCanvasSaveConflictPayload(existingProject.updatedAt),
{ status: 409 }
{ status: 409 },
);
}
if (!existingProject) {
// The parent must exist before node/asset foreign keys are synchronized.
const [created] = await tx
.insert(canvasProject)
.values({
id,
userId: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
workspaceType: workspaceType || "canvas",
canvasData: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
.onConflictDoNothing({ target: canvasProject.id })
.returning();
if (!created)
return NextResponse.json(
{ success: false, error: "画布已被创建,请重新加载后保存" },
{ status: 409 },
);
}
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
? await syncCanvasNodesForProject(
{ projectId: id, userId: session.user.id, canvasData },
tx,
)
: undefined;
// 更新现有画布
const updateData: any = { updatedAt: new Date() };
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) };
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
if (workspaceType !== undefined) updateData.workspaceType = workspaceType;
if (syncedCanvasData !== undefined) updateData.canvasData = syncedCanvasData;
const [updatedProject] = await db
if (syncedCanvasData !== undefined)
updateData.canvasData = syncedCanvasData;
const [saved] = await tx
.update(canvasProject)
.set(updateData)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id)
)
eq(canvasProject.userId, session.user.id),
),
)
.returning();
return NextResponse.json({ success: true, data: updatedProject });
} else {
const syncedCanvasData =
canvasData !== undefined
? await syncCanvasNodesForProject({
projectId: id,
userId: session.user.id,
canvasData,
})
: undefined;
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 });
}
return NextResponse.json({ success: true, data: saved });
});
} catch (error) {
console.error('更新/创建画布失败:', error);
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 });
console.error("更新/创建画布失败:", error);
return NextResponse.json(
{ success: false, error: "更新画布失败" },
{ status: 500 },
);
}
}

View 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 });
}

View 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 });
}

View File

@ -0,0 +1,40 @@
import { PROJECT_AGENT_OPERATIONS, projectOperationAuthorized } from "@/lib/ai/canvas-agent-runtime/project-operation-catalog";
import { NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { getSystemSetting } from "@/lib/db/queries";
import {
normalizeVideoCanvasFunctionConfig,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
} from "@/lib/video-canvas/config";
import { getCanvasAgentAccessManifest } from "@/lib/ai/canvas-agent-runtime/capability-access";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const parsed = z.string().uuid().safeParse(new URL(request.url).searchParams.get("projectId"));
if (!parsed.success) return NextResponse.json({ error: "画布编号无效" }, { status: 400 });
const [project] = await db
.select({ id: canvasProject.id, workspace: canvasProject.workspaceType })
.from(canvasProject)
.where(and(eq(canvasProject.id, parsed.data), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const config = normalizeVideoCanvasFunctionConfig(
(await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }))?.value,
);
return NextResponse.json(
{
workspace: project.workspace,
projectServices: PROJECT_AGENT_OPERATIONS.map(item => ({ ...item, authorized: projectOperationAuthorized(item) })),
utilityTools: ["getWeather", "createDocument", "updateDocument", "requestSuggestions", "loadProjectSkill"],
capabilities: getCanvasAgentAccessManifest(config).filter(
(item) => item.workspace === (project.workspace === "video" ? "video" : "canvas"),
),
},
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

@ -0,0 +1,491 @@
import { agentModelCandidates } from "@/lib/ai/runtime/agent-model-failover";
import { agentModelCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { createAgentVisionExecutor } from "@/lib/ai/runtime/agent-vision-failover";
import { canvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
import { registerProjectAgentTools } from "@/lib/ai/canvas-agent-runtime/project-tools";
import { isZcodeWorkspaceAuthorized } from "@/lib/ai/canvas-agent-runtime/authorization";
import {
createUIMessageStream,
createUIMessageStreamResponse,
generateText,
} from "ai";
import { after, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { siteSettings, canvasProject } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { postRequestBodySchema } from "@/app/(chat)/api/chat/schema";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
getAgentAiModelWithProvider,
getAccessibleAgentPresetById,
getEnabledAiModelsWithProvider,
getChatById,
getMessagesByChatId,
getMessageCountByUserId,
getSiteSettings,
saveChat,
saveMessages,
} from "@/lib/db/queries";
import { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
import { canvasAgentReplyText } from "@/lib/ai/zcode-agent/assistant-output";
import { createZcodeModelGateway } from "@/lib/ai/zcode-agent/model-gateway";
import { newAgentRunSnapshot, publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import {
acquireAgentRun,
agentRunView,
AgentRunError,
checkpointAgentRun,
consumeAgentRunInputs,
heartbeatAgentRun,
} from "@/lib/ai/canvas-agent-runtime/store";
import { readOwnedCanvas } from "@/lib/ai/canvas-agent-runtime/canvas";
import { buildCanvasRuntimeTools } from "@/lib/ai/canvas-agent-runtime/tools";
import { canvasRuntimeSystemPrompt } from "@/lib/ai/canvas-agent-runtime/prompt";
import { verifyCanvasAgentResults } from "@/lib/ai/canvas-agent-runtime/review";
import { inferCanvasTaskRequirements } from "@/lib/ai/canvas-agent-runtime/requirements";
import {
resolveRuntimeConversationSources,
persistRuntimeConversationResults,
} from "@/lib/ai/canvas-agent-runtime/conversation";
import { getCanvasAgentFeatureFlagsFromSystemSettings } from "@/lib/canvas/agent-feature-settings";
import { canApplyCanvasAgentMutations } from "@/lib/canvas/agent-feature-flags";
import {
videoAgentSettingsSchema,
inferVideoTaskRequirements,
videoNodeUrl,
videoRuntimeSystemPrompt,
} from "@/lib/ai/canvas-agent-runtime/video-protocol";
import { readOwnedVideoCanvas } from "@/lib/ai/canvas-agent-runtime/video-store";
import {
buildVideoRuntimeTools,
createVideoRuntimePorts,
} from "@/lib/ai/canvas-agent-runtime/video-tools";
import { probeVideoMetadata } from "@/lib/ai/canvas-agent-runtime/video-probe";
import { verifyVideoAgentResults } from "@/lib/ai/canvas-agent-runtime/video-review";
export const runtime = "nodejs";
export const maxDuration = 1200;
const requestSchema = postRequestBodySchema.extend({
resumeRunId: z.string().uuid().optional(),
selectedImageModel: z.string().min(1).max(180).optional(),
workspaceVariant: z.enum(["canvas", "video"]).default("canvas"),
videoAgentSettings: videoAgentSettingsSchema.optional(),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = requestSchema.parse(await request.json());
if (!shouldUseCanvasAgentRuntime(body))
throw new AgentRunError("此请求不属于画布 Agent 任务", 400);
const projectId = body.contextPayload!.projectId!;
const [ownedProject] = await db.select({ userId: canvasProject.userId, workspaceType: canvasProject.workspaceType })
.from(canvasProject).where(eq(canvasProject.id, projectId)).limit(1);
if (!ownedProject || ownedProject.userId !== session.user.id) throw new AgentRunError("画布不存在", 404);
if (!isZcodeWorkspaceAuthorized(ownedProject.workspaceType)) throw new AgentRunError("视频画布暂未授权给 ZCode Agent,请使用手工功能。", 403);
const isVideo = ownedProject.workspaceType === "video";
const preset = body.agentPresetId ? await getAccessibleAgentPresetById({ id: body.agentPresetId, userId: session.user.id }) : null;
if (preset && String(preset.type) === "video") throw new AgentRunError("视频技能暂未授权给 ZCode Agent", 403);
if (body.agentPresetId && (!preset || preset.status !== "active")) throw new AgentRunError("此 Agent 预设不可用", 403);
const objective = body.message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (!objective) throw new AgentRunError("请描述需要完成的创作任务", 400);
const settings = await getSiteSettings();
if (
!settings.agentEnabled ||
!canApplyCanvasAgentMutations(await getCanvasAgentFeatureFlagsFromSystemSettings())
) {
throw new AgentRunError("管理员尚未开启画布 Agent 操作", 403);
}
const [usageSettings] = await db.select().from(siteSettings).limit(1);
if (usageSettings?.usageTimeEnabled) {
const time = new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(new Date());
if (
time < (usageSettings.usageTimeStart || "08:00") ||
time > (usageSettings.usageTimeEnd || "20:00")
)
throw new AgentRunError(usageSettings.usageTimeMessage || "当前不在系统使用时间段内", 403);
}
const count = await getMessageCountByUserId({ id: session.user.id, differenceInHours: 24 });
if (count > entitlementsByUserType[session.user.type].maxMessagesPerDay)
throw new AgentRunError("今日对话次数已达到限额", 429);
const imageCanvas = isVideo ? null : await readOwnedCanvas(session.user.id, projectId);
if (imageCanvas?.project.workspaceType === "video")
throw new AgentRunError("请使用视频画布 Agent 入口", 400);
const videoCanvas = isVideo ? await readOwnedVideoCanvas(session.user.id, projectId) : null;
const nodes = imageCanvas?.nodes || [];
const availableIds = isVideo
? videoCanvas!.nodes.map((node) => node.id)
: nodes.map((node) => node.id);
const selectedNodeIds = body.contextPayload?.selectedNodeIds || [];
if (selectedNodeIds.some((id) => !availableIds.includes(id))) {
throw new AgentRunError("选中图片尚未保存到画布,请等待保存完成后重试。");
}
const existingChat = await getChatById({ id: body.id });
if (existingChat && existingChat.userId !== session.user.id)
throw new AgentRunError("对话不存在", 404);
if (!existingChat)
await saveChat({
id: body.id,
userId: session.user.id,
title: objective.slice(0, 80),
visibility: "private",
});
const conversation = isVideo
? {
sourceNodeIds: selectedNodeIds,
targetReason: "video-selected-nodes",
previousContext: null,
}
: await resolveRuntimeConversationSources({
userId: session.user.id,
chatId: body.id,
projectId,
text: objective,
parts: body.message.parts,
nodes,
selectedNodeIds,
});
const candidates = agentModelCandidates(await getAgentAiModelWithProvider(), await getEnabledAiModelsWithProvider());
const configured = candidates[0];
if (!configured) throw new AgentRunError("请先在后台配置可用的多模态 Agent 对话模型", 503);
let model = await getLanguageModelById(configured.model.id);
for (const candidate of candidates.slice(1)) {
if (model) break;
model = await getLanguageModelById(candidate.model.id);
}
if (!model) throw new AgentRunError("Agent 模型暂不可用", 503);
const visionModel = model;
const executeVisionModel = createAgentVisionExecutor(candidates, agentModelCircuitStore, getLanguageModelById);
// Validate the user's image choice independently of the Agent planning model.
if (body.selectedImageModel) {
const images = await getStyleProImageModelEntries();
if (!images.some(({ model }) => model.id === body.selectedImageModel && model.healthStatus !== "auto_disabled"))
throw new AgentRunError("你选择的生图模型当前不可用,请重新选择;不会自动换模型生成。", 400);
}
const assistantMessageId = crypto.randomUUID();
const previousVideoMessages =
existingChat
? (await getMessagesByChatId({ id: body.id })).slice(-6).map((message) => ({
role: message.role as "user" | "assistant",
content: (message.parts as Array<{ type: string; text?: string }>)
.filter((part) => part.type === "text")
.map((part) => part.text || "")
.join("\n")
.slice(0, 4000),
}))
: [];
const initialSnapshot = newAgentRunSnapshot(objective, [
...previousVideoMessages,
{ role: "user", content: objective },
]);
initialSnapshot.workspace = isVideo ? "video" : "canvas";
if (isVideo) {
initialSnapshot.videoRequirements = inferVideoTaskRequirements(objective);
initialSnapshot.videoSettings = videoAgentSettingsSchema.parse({
...body.videoAgentSettings,
...(initialSnapshot.videoRequirements.duration
? { duration: initialSnapshot.videoRequirements.duration }
: {}),
...(initialSnapshot.videoRequirements.ratio
? { ratio: initialSnapshot.videoRequirements.ratio }
: {}),
});
}
initialSnapshot.sourceNodeIds = conversation.sourceNodeIds;
initialSnapshot.requirements = {
...inferCanvasTaskRequirements(objective, initialSnapshot.sourceNodeIds.length),
sourceCount: initialSnapshot.sourceNodeIds.length,
};
const run = await acquireAgentRun({
id: body.message.id,
userId: session.user.id,
projectId,
chatId: body.id,
assistantMessageId,
resumeRunId: body.resumeRunId,
snapshot: initialSnapshot,
});
const snapshot = run.snapshot;
snapshot.imagePreferences = {
...snapshot.imagePreferences,
// Each submitted turn captures the current controls, including switching back to Auto.
modelId: body.selectedImageModel,
parameters: body.imageParams || snapshot.imagePreferences?.parameters,
};
const taskSourceNodeIds = snapshot.sourceNodeIds ?? selectedNodeIds;
const context = {
recentConversation: previousVideoMessages,
preset: preset ? { name: preset.name, type: preset.type, promptTemplate: preset.promptTemplate, defaultModelId: preset.defaultModelId, defaultParams: preset.defaultParams, slots: preset.slots.map(slot => ({ title: slot.title, promptTemplate: slot.promptTemplate, defaultParams: slot.defaultParams })) } : null,
videoSettings: snapshot.videoSettings,
selectedNodeIds: taskSourceNodeIds,
nodes: nodes
.filter((node) => taskSourceNodeIds.includes(node.id))
.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
size: node.size,
title: node.data.title,
})),
skillInstructions: body.hiddenPrompt || undefined,
imagePreferences: snapshot.imagePreferences,
targetReason: conversation.targetReason,
previousConversationContext: conversation.previousContext,
};
await saveMessages({
messages: [
{
id: body.message.id,
chatId: body.id,
role: "user",
parts: body.message.parts,
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
let archiveVideoResults: (() => Promise<void>) | undefined;
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const emit = (type: string, data: unknown) => writer.write({ type, data, ...(type === "data-canvasAgentRun" ? { transient: true } : {}) } as never);
let cancelled = false;
let leaseLost = false;
const activeModelController = new AbortController();
const withModel: typeof executeVisionModel = (execute, signal) => executeVisionModel(execute,
signal ? AbortSignal.any([signal, activeModelController.signal]) : activeModelController.signal);
const heartbeat = setInterval(() => {
void heartbeatAgentRun(run)
.then((current) => {
if (!current) leaseLost = true;
if (current?.cancelRequested) cancelled = true;
if (cancelled || leaseLost) activeModelController?.abort();
})
.catch(() => {
leaseLost = true;
activeModelController?.abort();
});
emit("data-keepalive", { label: "canvas-agent-runtime", ts: Date.now() });
}, 10_000);
const textId = crypto.randomUUID();
writer.write({ type: "start", messageId: assistantMessageId });
writer.write({ type: "text-start", id: textId });
let streamedText = "";
const emitAssistantText = (delta: string) => {
streamedText += delta;
writer.write({ type: "text-delta", id: textId, delta });
};
try {
const videoPorts = isVideo
? await createVideoRuntimePorts({
userId: session.user.id,
chatId: body.id,
projectId,
runId: run.id,
assistantMessageId,
request,
snapshot,
emit,
})
: null;
archiveVideoResults = videoPorts?.archiveResults;
const tools = isVideo
? buildVideoRuntimeTools({
runId: run.id,
projectId,
selectedNodeIds,
snapshot,
ports: videoPorts!,
})
: await buildCanvasRuntimeTools({
userId: session.user.id,
chatId: body.id,
projectId,
runId: run.id,
assistantMessageId,
selectedNodeIds: taskSourceNodeIds,
request,
model: visionModel || model,
supportsVision: Boolean(visionModel),
withModel,
imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(
"\n",
),
emit,
});
await registerProjectAgentTools({ session, projectId, request, objective: snapshot.objective,
definitions: tools.definitions, executable: tools.executable, emit,
imagePreferences: snapshot.imagePreferences });
const gateway = await createZcodeModelGateway(configured, undefined, candidates);
const status = await runZcodeCanvasTask({
runId: run.id, snapshot, definitions: tools.definitions,
instructions: (isVideo ? videoRuntimeSystemPrompt : canvasRuntimeSystemPrompt)(snapshot.objective, snapshot.summary, snapshot.inputs.map(input => input.text), context),
context, gateway, signal: activeModelController.signal,
onAssistantText: emitAssistantText,
tools: tools.executable,
checkpoint: async (state, stateStatus) => {
if (leaseLost) throw new AgentRunError("任务执行已中断");
const saved = await checkpointAgentRun(run, state, stateStatus);
cancelled ||= saved.cancelRequested;
emit("data-canvasAgentRun", agentRunView(saved));
},
consumeInputs: async (state) => {
if (leaseLost) throw new AgentRunError("任务执行已中断");
const control = await consumeAgentRunInputs(run, state);
cancelled ||= control.cancelled;
return { cancelled };
},
isCancelled: async () => {
const current = await heartbeatAgentRun(run);
if (!current) throw new AgentRunError("任务执行已中断");
cancelled ||= current.cancelRequested;
return cancelled;
},
verify: async (state) =>
isVideo
? verifyVideoAgentResults({
snapshot: state,
nodes: (await readOwnedVideoCanvas(session.user.id, projectId)).nodes,
inspect: async (node, requirements) => {
const metadata = await probeVideoMetadata(videoNodeUrl(node));
const report = await videoPorts!.analyze({
mode: "video-analysis",
prompt: `请基于真实抽帧画面检查主体、场景、镜头连贯性,说明不确定内容。用户要求:${requirements}`,
imageUrls: [],
videoUrls: [videoNodeUrl(node)],
});
const verdict = await generateText({
model,
system:
'你是视频验收员。依据实际抽帧分析判断是否满足用户要求;不能从文件名或提示词判断。若报告无法读取真实帧,必须不通过。抽帧只能检查画面,不能证明音频、精确时长或完整运动,涉及这些要求无法确认时说明需要人工检查。仅返回 JSON {"passed":boolean,"issues":string[]}。',
prompt: JSON.stringify({ requirements, report }),
maxOutputTokens: 1200,
maxRetries: 0,
abortSignal: AbortSignal.timeout(60_000),
});
state.inputTokens += verdict.usage.inputTokens || 0;
state.outputTokens += verdict.usage.outputTokens || 0;
const match = verdict.text.match(/\{[\s\S]*\}/);
if (!match) throw new Error("视频检查未返回有效结果");
return {
...z
.object({ passed: z.boolean(), issues: z.array(z.string()).max(12) })
.parse(JSON.parse(match[0])),
...metadata,
};
},
})
: verifyCanvasAgentResults({
snapshot: state,
userId: session.user.id,
projectId,
model: visionModel || model,
supportsVision: Boolean(visionModel),
withModel,
signal: activeModelController.signal,
}),
});
const finalText = canvasAgentReplyText(snapshot);
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot, status }));
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
if (!isVideo)
await persistRuntimeConversationResults({
userId: session.user.id,
chatId: body.id,
projectId,
assistantMessageId,
snapshot,
}).catch((error) => {
console.warn("[Canvas Agent] Conversation memory update failed", {
runId: run.id,
error: publicAgentError(error),
});
});
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: body.id,
role: "assistant",
parts: [{ type: "text", text: finalText }, { type: "data-canvasAgentProgress", data: progress }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
emitAssistantText(finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : `\n\n${snapshot.message}`);
} catch (error) {
const statusText = publicAgentError(error);
const text = canvasAgentReplyText({ ...snapshot, message: statusText });
await checkpointAgentRun(run, { ...snapshot, message: statusText }, "needs_review").catch(
() => undefined,
);
const progress = canvasAgentProgress(agentRunView({ ...run, snapshot: { ...snapshot, message: statusText }, status: "needs_review" }));
writer.write({ type: "data-canvasAgentProgress", id: run.id, data: progress });
await saveMessages({ messages: [{ id: assistantMessageId, chatId: body.id, role: "assistant",
parts: [{ type: "text", text }, { type: "data-canvasAgentProgress", data: progress }],
attachments: [], createdAt: new Date(), videoStorageStatus: null, temporaryVideoUrl: null,
permanentVideoUrl: null, archivedAt: null, videoMetadata: null }] }).catch(() => undefined);
emitAssistantText(text.startsWith(streamedText) ? text.slice(streamedText.length) : `\n\n${statusText}`);
} finally {
clearInterval(heartbeat);
writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish" });
}
},
});
// Browser disconnection does not cancel paid image work. Keep consuming on the server.
const [client, background] = stream.tee();
after(async () => {
const reader = background.getReader();
try {
while (!(await reader.read()).done) {
/* Drain the server branch. */
}
} finally {
reader.releaseLock();
await archiveVideoResults?.();
}
});
return createUIMessageStreamResponse({
stream: client,
headers: { "Cache-Control": "no-store", "X-Accel-Buffering": "no" },
});
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error), message: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}

View File

@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
AgentRunError,
agentRunView,
assertAgentRunOwnership,
controlAgentRun,
getAgentRun,
} from "@/lib/ai/canvas-agent-runtime/store";
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
export const dynamic = "force-dynamic";
const scope = z.object({
chatId: z.string().uuid(),
projectId: z.string().uuid(),
runId: z.string().uuid().optional(),
});
const control = scope
.extend({
runId: z.string().uuid(),
action: z.enum(["steer", "cancel"]),
inputId: z.string().uuid().optional(),
text: z.string().trim().min(1).max(2000).optional(),
})
.refine(
(input) => input.action !== "steer" || Boolean(input.inputId && input.text),
"补充要求缺少内容或请求号",
);
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const input = scope.parse(Object.fromEntries(new URL(request.url).searchParams));
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
const run = await getAgentRun(session.user.id, input.chatId, input.projectId, input.runId);
return NextResponse.json(
{ run: run ? agentRunView(run) : null },
{ headers: { "Cache-Control": "private, no-store" } },
);
} catch (error) {
return failure(error);
}
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const input = control.parse(await request.json());
await assertAgentRunOwnership(session.user.id, input.projectId, input.chatId);
return NextResponse.json({ run: await controlAgentRun({ ...input, userId: session.user.id }) });
} catch (error) {
return failure(error);
}
}
function failure(error: unknown) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}

View 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 });
}
}

View 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 });
}
}

View 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");
}

View File

@ -25,7 +25,7 @@ export async function GET(request: Request) {
const url = new URL(request.url);
const limit = Math.max(
1,
Math.min(100, Math.round(Number(url.searchParams.get("limit") || 50)))
Math.min(500, Math.round(Number(url.searchParams.get("limit") || 100)))
);
const chats = await getClawdChatsByUserId({

View File

@ -29,6 +29,7 @@ import {
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
type OpenClawChatMediaAttachment,
} from "@/lib/openclaw-chat/media-attachments";
import { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
import {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
@ -298,7 +299,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
params.spreadsheetMarkdownTruncated || undefined,
...params.metadata,
},
stream_progress: false,
stream_progress: !params.sensitiveInput,
sensitive_input: params.sensitiveInput,
};
@ -337,6 +338,9 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
assistantMessageId: params.assistantMessageId,
answer,
requestId: getRequestId(payload),
artifacts: extractOpenClawArtifacts(payload, {
baseUrl: params.config.baseUrl,
}),
});
clearClawdRequestCancellation(params.cancelKey);
return;
@ -370,6 +374,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
...target,
answer: outcome.answer,
requestId: outcome.requestId,
artifacts: outcome.artifacts,
});
} else if (outcome.status === "canceled") {
await markClawdTaskCanceled({
@ -576,7 +581,7 @@ export async function POST(request: Request) {
: "image/jpeg"),
}));
await persistPendingClawdTask({
const persistedTask = await persistPendingClawdTask({
chatId: parsed.id,
userMessageId,
assistantMessageId,
@ -587,6 +592,22 @@ export async function POST(request: Request) {
skillTagName,
processingHint,
});
if (
persistedTask &&
persistedTask.assistantMessageId !== assistantMessageId
) {
return NextResponse.json(
buildTaskResponse({
chatId: parsed.id,
userMessageId,
assistantMessageId: persistedTask.assistantMessageId,
status: persistedTask.status,
processingHint:
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
}),
{ status: 202, headers: { "Retry-After": "2" } }
);
}
const userProfile = await getUserById({ id: session.user.id }).catch(
() => null

View File

@ -75,6 +75,7 @@ async function reconcileDetachedOpenClawTask(params: {
...target,
answer: outcome.answer,
requestId: outcome.requestId,
artifacts: outcome.artifacts,
});
} else if (outcome.status === "failed") {
await markClawdTaskFailed({ ...target, error: outcome.error });

View 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 },
);
}
}

View File

@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
import { after, NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
import {
buildAttachmentContentDisposition,
fetchCanvasDownloadAsset,
@ -9,6 +13,31 @@ import {
export const runtime = "nodejs";
export const maxDuration = 120;
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
// A proxy candidate for the same original must not repeat the same slow
// upstream request through our own server (and create another HTTP timeout).
const sources = [...new Set(input.urls.map(url => {
const source = extractImageProxySourceUrl(url) || url;
return inferOriginalImageUrl(source) || source;
}))];
for (let attempt = 0; attempt < 3; attempt++) {
signal.throwIfAborted();
for (const url of sources) {
signal.throwIfAborted();
const result = await fetchCanvasDownloadAsset({
file: { url, name: "original" }, index: 0,
requestOrigin: input.requestOrigin, cookie: input.cookie,
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
});
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
}
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
// No AI/model request is involved and a completed source fetch is shared.
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
}
throw new Error("Original image sources unavailable");
}
function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
@ -55,12 +84,41 @@ export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request);
const urls = resolveDownloadUrls(body, requestOrigin);
const requestedUrls = [...urls];
if (urls.length === 0) {
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
}
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
if (body.preferDirect === true) {
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
if (task?.targetUrl) {
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
after(() => prepareImageArchiveDownload(task.id));
}
}
const cookie = request.headers.get("cookie") || "";
if (body.preparation === "async") {
const result = await getDownloadPreparationQueue(prepareDownload, after).request({
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
});
if (result.status === "preparing") {
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
message: "正在准备原图,完成后将自动下载" }, {
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
});
}
if (result.status === "failed") {
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
}
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
"Content-Type": result.asset.contentType,
"Content-Length": String(result.asset.buffer.length),
"Content-Disposition": buildAttachmentContentDisposition(`${name}.png`),
"Cache-Control": "no-store",
} });
}
let result: Awaited<ReturnType<typeof fetchCanvasDownloadAsset>> | null = null;
const failures: string[] = [];

View File

@ -1,54 +1,22 @@
import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream";
import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store";
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
import { createReadStream } from "node:fs";
import { mkdir, stat, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { after, NextRequest, NextResponse } from "next/server";
import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
export const runtime = "nodejs";
export const maxDuration = 300;
type DownloadFileItem = {
url: string;
name?: string;
};
type ZipTaskStatus = "pending" | "running" | "completed" | "failed";
type ZipTask = {
id: string;
userId: string;
folderName: string;
files: DownloadFileItem[];
totalCount: number;
processedCount: number;
successCount: number;
failedCount: number;
compressPercent: number;
status: ZipTaskStatus;
stage: string;
error?: string;
zipBuffer?: Buffer;
contentDisposition?: string;
createdAt: number;
updatedAt: number;
expiresAt: number;
};
const MAX_CONCURRENCY = 8;
type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 30 * 60 * 1000;
const globalTaskStore = globalThis as typeof globalThis & {
__canvasDownloadZipTasks?: Map<string, ZipTask>;
};
function getTaskStore() {
if (!globalTaskStore.__canvasDownloadZipTasks) {
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
}
return globalTaskStore.__canvasDownloadZipTasks;
}
const TASK_TTL_MS = 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string {
const cleaned = String(value || "")
.replace(/[\\/:*?"<>|]+/g, "-")
@ -56,7 +24,7 @@ function sanitizeFolderName(value: string): string {
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.trim();
return cleaned || `canvas-images-${Date.now()}`;
return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`;
}
function buildContentDisposition(folderName: string): string {
@ -110,258 +78,161 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
.slice(0, MAX_DOWNLOAD_FILES);
}
function cleanupExpiredTasks() {
const now = Date.now();
const store = getTaskStore();
for (const [taskId, task] of store.entries()) {
if (task.expiresAt <= now) {
store.delete(taskId);
}
}
}
function getTaskProgress(task: ZipTask) {
if (task.status === "completed") {
return 100;
}
if (task.status === "failed") {
return Math.max(
0,
Math.min(100, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
);
}
if (task.processedCount >= task.totalCount && task.totalCount > 0) {
return Math.min(99, 85 + Math.round((task.compressPercent / 100) * 14));
}
return Math.max(
4,
Math.min(85, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
);
}
function serializeTask(task: ZipTask) {
const downloadUrl =
task.status === "completed"
? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1`
: "";
return {
id: task.id,
folderName: task.folderName,
status: task.status,
stage: task.stage,
totalCount: task.totalCount,
processedCount: task.processedCount,
successCount: task.successCount,
failedCount: task.failedCount,
progress: getTaskProgress(task),
error: task.error || "",
downloadUrl,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
expiresAt: task.expiresAt,
};
return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage,
totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount,
failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99,
Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)),
error: task.error || "", expiresAt: task.expiresAt,
downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" };
}
async function processZipTask(params: {
task: ZipTask;
requestOrigin: string;
cookie: string;
}) {
const { task, requestOrigin, cookie } = params;
const store = getTaskStore();
async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) {
if (active.has(id) || active.size >= 2) return;
active.add(id);
let release: (() => Promise<void>) | null = null;
let task: ZipTask | null = null;
try {
task.status = "running";
task.stage = "正在读取源图";
task.updatedAt = Date.now();
const zip = new JSZip();
const folder = zip.folder(task.folderName) || zip;
const results: Array<
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>>
| null
> = new Array(task.files.length).fill(null);
release = await claimZipTask(id);
if (!release) return;
task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace();
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
await saveZipTask(task);
// Stage each image on disk, not all PNG buffers in the web process heap.
// A restart resumes from these files using the current owner's session.
let cursor = 0;
const workerCount = Math.min(MAX_CONCURRENCY, task.files.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const index = cursor;
cursor += 1;
if (index >= task.files.length) break;
const file = task.files[index]!;
results[index] = await fetchCanvasDownloadAsset({
file,
index,
requestOrigin,
cookie,
});
const result = results[index];
task.processedCount += 1;
if (result?.ok) {
task.successCount += 1;
} else {
task.failedCount += 1;
}
task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`;
task.updatedAt = Date.now();
}
})
);
const failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
for (const [index, item] of results.entries()) {
if (!item || !item.ok) {
failures.push({
index: index + 1,
url: item?.ok === false ? item.url : task.files[index]?.url || "",
name: item?.ok === false ? item.name : task.files[index]?.name,
error: item?.ok === false ? item.error : "未知错误",
});
continue;
let writes = Promise.resolve();
const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; };
const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => {
while (cursor < task!.files.length) {
const index = cursor++; const file = task!.files[index];
const path = join(zipTaskPath(id), `${index}.image`);
if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue;
delete file.prepared; delete file.filename;
const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie,
signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" }));
if (result.ok) {
await assertZipDiskSpace(result.bytes.length);
await writeFile(path, result.bytes, { mode: 0o600 });
file.prepared = `${index}.image`; file.filename = result.filename; delete file.error;
} else file.error = result.error;
task!.processedCount = task!.files.filter(f => f.prepared || f.error).length;
task!.successCount = task!.files.filter(f => f.prepared).length;
task!.failedCount = task!.files.filter(f => f.error).length;
task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`;
task!.updatedAt = Date.now(); await persist();
}
folder.file(item.filename, item.bytes);
}
if (failures.length > 0) {
folder.file(
"_download-failures.txt",
[
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
"",
...failures.map((item) => {
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
}),
].join("\n\n")
);
console.warn("[CanvasDownloadZipTask] Partial download failures:", failures);
}
task.stage = "正在生成 ZIP 文件";
task.updatedAt = Date.now();
task.zipBuffer = await zip.generateAsync(
{
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
},
(metadata) => {
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
task.updatedAt = Date.now();
}
);
}));
const rejected = results.find(result => result.status === "rejected");
if (rejected?.status === "rejected") throw rejected.reason;
if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除");
const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip;
for (const file of task.files) if (file.prepared && file.filename)
folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))
.once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); }));
if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error)
.map(f => `${f.name || "图片"}:${f.error}`).join("\n"));
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName);
task.status = "completed";
task.stage = "打包完成";
task.compressPercent = 100;
task.updatedAt = Date.now();
task.expiresAt = Date.now() + TASK_TTL_MS;
} catch (error) {
console.error("[CanvasDownloadZipTask] Failed:", error);
task.status = "failed";
task.stage = "打包失败";
task.error = error instanceof Error ? error.message : "打包下载失败";
task.updatedAt = Date.now();
task.expiresAt = Date.now() + TASK_TTL_MS;
} finally {
store.set(task.id, task);
}
}
export async function POST(request: NextRequest) {
try {
cleanupExpiredTasks();
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const requestOrigin = getRequestOrigin(request);
const files = toDownloadItems(body.files, requestOrigin);
const folderName = sanitizeFolderName(String(body.folderName || ""));
if (files.length === 0) {
return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 });
}
const task: ZipTask = {
id: randomUUID(),
userId: session.user.id,
folderName,
files,
totalCount: files.length,
processedCount: 0,
successCount: 0,
failedCount: 0,
compressPercent: 0,
status: "pending",
stage: "等待服务端开始打包",
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + TASK_TTL_MS,
};
getTaskStore().set(task.id, task);
void processZipTask({
task,
requestOrigin,
cookie: request.headers.get("cookie") || "",
});
return NextResponse.json({
success: true,
task: serializeTask(task),
});
} catch (error) {
console.error("[CanvasDownloadZipTask] Create failed:", error);
return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
try {
cleanupExpiredTasks();
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim();
if (!taskId) {
return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 });
}
const task = getTaskStore().get(taskId);
if (!task || task.userId !== session.user.id) {
return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 });
}
if (request.nextUrl.searchParams.get("download") === "1") {
if (task.status !== "completed" || !task.zipBuffer) {
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
task.stage = "正在准备下载链接"; await saveZipTask(task);
try {
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (cdn.url) task.cdnUrl = cdn.url;
}
return new NextResponse(task.zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
"Content-Length": String(task.zipBuffer.length),
"Cache-Control": "no-store",
"X-Download-Success-Count": String(task.successCount),
"X-Download-Failed-Count": String(task.failedCount),
},
});
}
return NextResponse.json({
success: true,
task: serializeTask(task),
});
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes.
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
} catch (error) {
console.error("[CanvasDownloadZipTask] Query failed:", error);
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 });
if (task) {
task.status = "failed"; task.stage = "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
}
console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error });
} finally { await release?.().catch(() => undefined); active.delete(id); }
}
function schedule(task: ZipTask, request: NextRequest) {
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
after(run);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = await request.json();
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
let task = await loadZipTask(id, session.user.id);
if (!task) {
await assertZipTaskCapacity(session.user.id);
if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID();
const files = toDownloadItems(body.files, getRequestOrigin(request));
if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length)
return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 });
// Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest.
await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => {
if (error.code !== "ENOENT") throw error;
await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 });
await mkdir(zipTaskPath(id), { mode: 0o700 });
});
const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
await saveZipTask(task);
} else if (task.status === "failed" && body.retry === true) {
task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id);
for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
await saveZipTask(task);
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
} catch (error) {
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });
return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 });
}
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const id = request.nextUrl.searchParams.get("taskId") || "";
const task = await loadZipTask(id, session.user.id);
if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 });
if (request.nextUrl.searchParams.get("download") === "1") {
if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 });
if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1")
return NextResponse.redirect(task.cdnUrl, 307);
const info = await stat(task.zipFile.path).catch(() => null);
if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 });
const etag = `"${task.id}-${info.size}"`;
const headers: Record<string, string> = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!,
"Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store",
"X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) };
const ifRange = request.headers.get("if-range");
let range;
try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); }
catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); }
headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size);
if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`;
return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers });
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } });
} catch {
return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 });
}
}

View File

@ -1,3 +1,4 @@
import { streamDownloadZip } from "@/lib/canvas/download-zip-stream";
import { NextRequest, NextResponse } from "next/server";
import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
@ -159,13 +160,7 @@ export async function POST(request: NextRequest) {
console.warn("[CanvasDownloadZip] Partial download failures:", failures);
}
const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
return new NextResponse(zipBuffer, {
return new NextResponse(streamDownloadZip(zip), {
status: 200,
headers: {
"Content-Type": "application/zip",

View File

@ -1,62 +1,10 @@
import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import {
generateCanvasImage,
type CanvasGenerateImageRequest,
} from "@/lib/canvas/generate-image";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
getImageToolCreditCost,
type ImageToolCreditFeature,
} from "@/lib/points/config";
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
export const maxDuration = 1200;
interface GenerateImageRequest extends CanvasGenerateImageRequest {
async?: boolean;
}
function normalizeBillingFeature(
value: unknown
): Extract<ImageToolCreditFeature, "multi-view" | "repaint"> | null {
if (value === "multi-view" || value === "repaint") return value;
return null;
}
function toGenerateImagePayload(body: GenerateImageRequest): CanvasGenerateImageRequest {
return {
prompt: String(body.prompt || "").trim(),
negativePrompt:
typeof body.negativePrompt === "string" ? body.negativePrompt : undefined,
model: typeof body.model === "string" ? body.model : undefined,
aspectRatio:
typeof body.aspectRatio === "string" && body.aspectRatio.trim()
? body.aspectRatio.trim()
: "auto",
imageSize:
typeof body.imageSize === "string" && body.imageSize.trim()
? body.imageSize.trim()
: undefined,
imageCount:
typeof body.imageCount === "number" && Number.isFinite(body.imageCount)
? body.imageCount
: 1,
promptExtend:
typeof body.promptExtend === "boolean" ? body.promptExtend : undefined,
referenceImage:
typeof body.referenceImage === "string" ? body.referenceImage : undefined,
referenceImages: Array.isArray(body.referenceImages)
? body.referenceImages.map((item) => String(item || "").trim()).filter(Boolean)
: [],
};
}
export async function GET(request: Request) {
try {
const session = await auth();
@ -67,10 +15,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const taskId = String(searchParams.get("taskId") || "").trim();
if (!taskId) {
return NextResponse.json(
{ success: false, error: "缺少 taskId 参数" },
{ status: 400 }
);
return NextResponse.json({ success: false, error: "缺少 taskId 参数" }, { status: 400 });
}
const task = await getCanvasImageTaskForUser({ taskId, userId: session.user.id });
@ -99,105 +44,15 @@ export async function GET(request: Request) {
success: false,
error: error instanceof Error ? error.message : "查询任务失败",
},
{ status: 500 }
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body: GenerateImageRequest = await request.json();
const payload: CanvasGenerateImageRequest = {
...toGenerateImagePayload(body),
ownerKey: session.user.id,
};
if (!payload.prompt) {
return NextResponse.json(
{ success: false, error: "请输入提示词" },
{ status: 400 }
);
}
const billingFeature = normalizeBillingFeature(body.billingFeature);
const pointsCharge = billingFeature
? await (async () => {
const pointsConfig = await getPointsSystemConfig();
return prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, billingFeature),
reason:
billingFeature === "multi-view"
? "image_multi_view"
: "image_repaint",
modelId: "canvas-generate-image",
note:
billingFeature === "multi-view"
? "图片多视角生成消耗积分"
: "图片重绘消耗积分",
pointsConfig,
});
})()
: null;
if (pointsCharge && !pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const asyncMode = body.async === true;
if (asyncMode) {
const task = await createCanvasImageTask({
userId: session.user.id,
request: payload as unknown as Record<string, unknown>,
});
await enqueueCanvasImageTask(task.id);
const currentTask = await getCanvasImageTaskForUser({
taskId: task.id,
userId: session.user.id,
});
if (pointsCharge?.chargeable) {
try {
await pointsCharge.consume({
note:
billingFeature === "multi-view"
? `图片多视角生成消耗积分(${task.id})`
: `图片重绘消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume canvas image task points:", pointsError);
}
}
return NextResponse.json({
success: true,
taskId: task.id,
status: currentTask?.status || task.status,
queueStatus: currentTask?.queueStatus,
});
}
const result = await generateCanvasImage(payload);
if (pointsCharge?.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume canvas image points:", pointsError);
}
}
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
console.error("[Canvas Generate Image] Error:", error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "生成失败" },
{ status: 500 }
);
}
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
const body = await request.json().catch(() => ({}));
return executeCanvasImageResponse(body, session.user.id);
}

View File

@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { executeDirectVideoGeneration } from "@/lib/ai/tools/generate-video";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import { probeRemoteUrl } from "@/lib/net/url-access";
import { validateVideoReferences } from "@/lib/video-canvas/reference-input";
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
export const maxDuration = 300;
@ -10,6 +11,8 @@ interface GenerateVideoRequest {
prompt?: string;
mode?: VideoGenerationMode;
images?: string[];
audioUrls?: string[];
generateAudio?: boolean;
resolution?: VideoResolution;
ratio?: VideoRatio;
duration?: number;
@ -46,23 +49,18 @@ export async function POST(request: Request) {
);
}
const inputImages = Array.isArray(body.images)
? body.images.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 4)
: [];
const { kept: reachableImages, dropped: unreachableImages } =
await filterReachableRemoteUrls(inputImages, { timeoutMs: 3000 });
if (inputImages.length > 0 && reachableImages.length === 0) {
return NextResponse.json(
{ success: false, error: "参考图片链接不可访问,请重新上传后再试" },
{ status: 400 }
);
const referenceCheck = await validateVideoReferences(body.images, url => probeRemoteUrl(url, { timeoutMs: 8000 }));
if (referenceCheck.error) {
return NextResponse.json({ success: false, error: referenceCheck.error }, { status: referenceCheck.status });
}
const reachableImages = referenceCheck.images;
const mode = body.mode || inferMode(reachableImages);
const result = await executeDirectVideoGeneration({
prompt,
mode,
audioUrls: Array.isArray(body.audioUrls) ? body.audioUrls.filter(url => typeof url === "string" && /^https?:\/\//i.test(url)).slice(0, 3) : undefined,
generateAudio: body.generateAudio,
images: reachableImages,
resolution: body.resolution,
ratio: body.ratio,
@ -90,13 +88,13 @@ export async function POST(request: Request) {
success: true,
data: {
url: result.videoUrl,
metadata: result.metadata || null,
metadata: { ...result.metadata, temporaryVideoUrl: result.videoUrl, storageStatus: result.metadata?.permanentVideoUrl === result.videoUrl ? "archived" : "temporary" },
modelDisplayName: result.modelDisplayName,
modelId: result.modelId,
modelRef: result.modelRef,
mode,
images: reachableImages,
droppedImages: unreachableImages,
droppedImages: [],
},
});
} catch (error) {

View 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);
}
}

View 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 }
);
}
}

View 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" } }
);
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 },
);
}
}

View 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 });
}
}

View 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);
}
}

View File

@ -100,6 +100,8 @@ export async function POST(request: NextRequest) {
.insert(canvasProject)
.values({
userId: session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
title: title || getDefaultWorkspaceTitle(workspaceType),
description,
workspaceType,

View 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 }
);
}
}

View 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 });
}
}

View File

@ -29,8 +29,9 @@ export const runtime = "nodejs";
export const maxDuration = 1200;
const logger = createLogger("canvas-text-edit");
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 120_000;
const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3;
const TEXT_EDIT_GENERATION_TIMEOUT_MS = 180_000;
const textItemSchema = z.object({
id: z.string().min(1),
@ -68,6 +69,13 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
type TextEditModelCandidate = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
function isExplicitTextEditModel(candidate: TextEditModelCandidate) {
const capabilities = candidate.model.capabilities as Record<string, unknown> | null;
const kind = String(capabilities?.kind || "").toLowerCase();
const modelId = String(candidate.model.modelId || "").toLowerCase();
return kind === "image-edit" || /(?:^|[/_-])edit(?:$|[/_-])/.test(modelId);
}
function getTextEditModelCandidates(
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>
) {
@ -82,16 +90,19 @@ function getTextEditModelCandidates(
}) &&
item.model.isEnabled &&
item.provider.isEnabled &&
(!item.model.excludeFromFailover || item.model.isDefaultTextEditModel) &&
SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type),
)
.sort((a, b) => {
const aScore =
(a.model.isDefaultTextEditModel ? 100 : 0) +
(isExplicitTextEditModel(a) ? 30 : 0) +
(a.model.isDefault ? 10 : 0) +
(a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
(a.model.displayName?.includes("火山") ? 3 : 0);
const bScore =
(b.model.isDefaultTextEditModel ? 100 : 0) +
(isExplicitTextEditModel(b) ? 30 : 0) +
(b.model.isDefault ? 10 : 0) +
(b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) +
(b.model.displayName?.includes("火山") ? 3 : 0);
@ -220,6 +231,13 @@ async function resolveTextEditSourceImageUrl(imageUrl: string) {
} catch (error) {
logger.warn("source_image_archive_failed", {
error: error instanceof Error ? error.message : String(error),
sourceHost: (() => {
try {
return new URL(imageUrl).host;
} catch {
return "invalid-url";
}
})(),
});
return imageUrl;
}
@ -495,6 +513,7 @@ async function generateTextEditImageWithFailover(params: {
],
imageParams: {
imageCount: 1,
timeoutMs: TEXT_EDIT_GENERATION_TIMEOUT_MS,
},
};
if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") {

View File

@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import {
readOwnedVideoCanvas,
saveVideoAgentCanvas,
} from "@/lib/ai/canvas-agent-runtime/video-store";
import { AgentRunError } from "@/lib/ai/canvas-agent-runtime/store";
import { publicAgentError } from "@/lib/ai/canvas-agent-runtime/protocol";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const projectId = z.string().uuid().parse(new URL(request.url).searchParams.get("projectId"));
await readOwnedVideoCanvas(session.user.id, projectId);
// One stable conversation per video workspace survives browser reloads.
const chat = await getChatById({ id: projectId });
if (chat && chat.userId !== session.user.id) throw new AgentRunError("对话不存在", 404);
const messages = chat ? (await getMessagesByChatId({ id: projectId })).slice(-40) : [];
return NextResponse.json(
{ messages: messages.map(({ id, role, parts }) => ({ id, role, parts })) },
{ headers: { "Cache-Control": "private, no-store" } },
);
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}
const saveSchema = z.object({
projectId: z.string().uuid(),
baseRevision: z.string().datetime(),
canvasData: z.object({
nodes: z.array(z.record(z.unknown())).max(2000),
edges: z
.array(
z.object({
id: z.string(),
source: z.string(),
target: z.string(),
type: z.string().optional(),
}),
)
.max(4000),
selectedNodeIds: z.array(z.string()).max(20),
viewport: z.object({
x: z.number().finite(),
y: z.number().finite(),
zoom: z.number().positive(),
}),
tool: z.literal("select"),
}),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
const body = saveSchema.parse(await request.json());
await saveVideoAgentCanvas(session.user.id, body.projectId, body.baseRevision, body.canvasData);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: publicAgentError(error) },
{
status:
error instanceof AgentRunError ? error.status : error instanceof z.ZodError ? 400 : 503,
},
);
}
}

View 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" } }
);
}
}

View 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); }
}

View 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

View File

@ -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: {
cacheKey: string;
sourceUrl?: string | null;
@ -249,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: {
);
const filename = `${params.filenamePrefix}.${ext}`;
const uploaded = await uploadFile(filename, assetBuffer);
await verifyArchivedAssetUpload({
url: uploaded.url,
expectedBytes: assetBuffer.byteLength,
validateAs: params.validateAs,
});
cache.set(params.cacheKey, {
sourceUrl,
archivedUrl: uploaded.url,
@ -299,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: {
)
).filter((entry): entry is readonly [string, string] => Boolean(entry));
return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null;
if (archivedEntries.length !== entries.length) return null;
return Object.fromEntries(archivedEntries);
}
function getStringValue(...values: unknown[]) {
@ -368,33 +416,96 @@ async function updatePersistedWorldAsset(params: {
function extractWorldId(operation: WorldLabsOperation) {
const response = getObjectValue(operation.response);
const metadata = getObjectValue(operation.metadata);
return getStringValue(
response?.world_id,
response?.worldId,
metadata?.world_id,
metadata?.worldId
);
const nestedWorldId =
getStringValue(response?.world_id, response?.worldId) ||
findNestedString(response, ["world_id", "worldId"]);
const metadataWorldId =
getStringValue(metadata?.world_id, metadata?.worldId) ||
findNestedString(metadata, ["world_id", "worldId"]);
return getStringValue(nestedWorldId, metadataWorldId);
}
function isWorldLikePayload(value: unknown): value is WorldLabsWorld {
const record = getObjectValue(value);
if (!record) return false;
const hasCoreWorldField =
typeof record.world_id === "string" ||
typeof record.world_marble_url === "string" ||
typeof record.display_name === "string" ||
typeof record.worldId === "string";
const hasPossibleWorldContainerFields = record.assets || record.permission || record.tags || record.world_prompt;
const hasModelAliasOnly =
typeof record.model === "string" && /world/i.test(record.model);
return Boolean(hasCoreWorldField || hasPossibleWorldContainerFields || hasModelAliasOnly);
}
function extractWorldCandidate(value: unknown, depth = 0): WorldLabsWorld | null {
if (depth > 8) return null;
const record = getObjectValue(value);
if (!record) return null;
if (isWorldLikePayload(record)) return record;
const childCandidates: Array<Record<string, unknown> | null> = [
getObjectValue(record.world),
getObjectValue(record.result),
getObjectValue(record.output),
getObjectValue(record.data),
getObjectValue(record.payload),
];
for (const child of childCandidates) {
if (!child) continue;
if (isWorldLikePayload(child)) return child;
const nested = extractWorldCandidate(child, depth + 1);
if (nested) return nested;
}
for (const child of Object.values(record)) {
const nested = extractWorldCandidate(child, depth + 1);
if (nested) return nested;
}
return null;
}
function extractWorldFromOperation(operation: WorldLabsOperation) {
const response = operation.response;
if (response && typeof response === "object" && "world_marble_url" in (response as Record<string, unknown>)) {
return response as WorldLabsWorld;
}
return null;
return extractWorldCandidate(operation.response);
}
function hasUsableWorldAssets(world?: WorldLabsWorld | null) {
const assets = world?.assets || null;
if (!assets) return false;
const spzUrls = assets.splats?.spz_urls;
const meshRecord = getObjectValue(assets.mesh);
const imageryRecord = getObjectValue(assets.imagery);
const splatsRecord = getObjectValue(assets.splats);
const spzUrls =
splatsRecord?.spz_urls || splatsRecord?.spzUrls || splatsRecord?.splat_urls;
return Boolean(
getStringValue(assets.thumbnail_url) ||
getStringValue(assets.imagery?.pano_url) ||
getStringValue(assets.mesh?.collider_mesh_url) ||
getStringValue(
imageryRecord?.pano_url,
imageryRecord?.panoUrl,
imageryRecord?.panorama_url,
imageryRecord?.panoramaUrl
) ||
getStringValue(
meshRecord?.collider_mesh_url,
meshRecord?.colliderMeshUrl,
meshRecord?.collider_mesh,
meshRecord?.mesh_url,
meshRecord?.meshUrl,
meshRecord?.model_url,
meshRecord?.modelUrl
) ||
(spzUrls &&
typeof spzUrls === "object" &&
Object.values(spzUrls).some((value) => typeof value === "string" && value.trim()))
Object.values(spzUrls).some(
(value) => typeof value === "string" && value.trim()
))
);
}
@ -667,185 +778,224 @@ export async function GET(request: Request, { params }: RouteParams) {
});
providerForLog = config.providerId || null;
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
const persistedAssetPayload =
let persistedAssetPayload =
getObjectValue(persistedWorldAsset?.assetPayload) || {};
const shouldUseExternalTaskResultApi =
let shouldUseExternalTaskResultApi =
isWorldLabsExternalTaskApi(config.baseUrl) &&
persistedAssetPayload.externalTaskApi !== false &&
persistedAssetPayload.marbleApi !== true;
if (shouldUseExternalTaskResultApi) {
const externalPayload = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
>(config, {
method: "GET",
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
const externalData =
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
const externalStatusText = getStringValue(externalData.status) || "running";
const externalStatus = normalizeExternalTaskStatus(
externalStatusText,
externalData.done === true
);
const externalAssetPayloadBase = {
...persistedAssetPayload,
providerId: config.providerId || null,
providerName: config.providerName || null,
externalTaskApi: true,
externalTaskStatus: externalStatusText,
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
};
if (externalStatus === "running") {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: null,
output: externalData,
assetPayload: externalAssetPayloadBase,
},
try {
const externalPayload = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
>(config, {
method: "GET",
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
}
if (externalStatus === "failed") {
const errorMessage = extractExternalTaskErrorMessage(
externalData,
externalPayload
const externalData =
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
const externalStatusText = getStringValue(externalData.status) || "running";
const externalStatus = normalizeExternalTaskStatus(
externalStatusText,
externalData.done === true
);
const externalAssetPayloadBase = {
...persistedAssetPayload,
providerId: config.providerId || null,
providerName: config.providerName || null,
externalTaskApi: true,
externalTaskStatus: externalStatusText,
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
};
if (externalStatus === "running") {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: null,
output: externalData,
assetPayload: externalAssetPayloadBase,
},
});
}
if (externalStatus === "failed") {
const errorMessage = extractExternalTaskErrorMessage(
externalData,
externalPayload
);
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "failed",
progress: null,
error: errorMessage,
assetPayload: externalAssetPayloadBase,
});
logger.warn("external task failed status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
error: errorMessage,
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "failed",
progress: null,
errorMessage,
output: externalData,
assetPayload: externalAssetPayloadBase,
},
});
}
const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
const rawMeshUrl = extractedAssetPayload.meshUrl;
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
const rawPanoUrl = extractedAssetPayload.panoUrl;
const stableWorldUrl: string | null = null;
const stableModelUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
sourceUrl: rawMeshUrl,
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
fallbackExtension: "glb",
validateAs: "glb",
});
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
sourceUrl: rawThumbnailUrl,
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
sourceUrl: rawPanoUrl,
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
sourceMap: extractedAssetPayload.spzUrls,
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
fallbackExtension: "spz",
validateAs: "spz",
});
const sourceSpzEntryCount = Object.keys(
extractedAssetPayload.spzUrls || {}
).length;
const stableSpzEntryCount = Object.keys(stableSpzUrls || {}).length;
const assetsArchivingPending = Boolean(
(rawMeshUrl && !stableModelUrl) ||
(rawThumbnailUrl && !stableThumbnailUrl) ||
(rawPanoUrl && !stablePanoUrl) ||
(sourceSpzEntryCount > 0 && stableSpzEntryCount !== sourceSpzEntryCount)
);
const storageArchivedAt = assetsArchivingPending
? null
: new Date().toISOString();
const assetPayload = {
...externalAssetPayloadBase,
worldId: extractedAssetPayload.worldId,
worldUrl: stableWorldUrl,
displayName: extractedAssetPayload.displayName,
model: resolveThreeDModelAliasId(
extractedAssetPayload.model,
modelAliasConfig.worldModels
),
caption: extractedAssetPayload.caption,
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
storageArchivedAt,
assetsArchivingPending,
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
? rawMeshUrl
: null,
sourceThumbnailUrl:
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
? rawThumbnailUrl
: null,
sourcePanoUrl:
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
? rawPanoUrl
: null,
sourceSpzUrls:
extractedAssetPayload.spzUrls &&
stableSpzUrls !== extractedAssetPayload.spzUrls
? extractedAssetPayload.spzUrls
: null,
};
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "failed",
progress: null,
error: errorMessage,
assetPayload: externalAssetPayloadBase,
status: "success",
progress: 100,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
error: null,
assetPayload,
});
logger.warn("external task failed status", {
logger.info("external task success status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
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({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "failed",
progress: null,
errorMessage,
status: "success",
progress: 100,
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, {
@ -908,22 +1058,68 @@ export async function GET(request: Request, { params }: RouteParams) {
let world = extractWorldFromOperation(operation);
const worldId = extractWorldId(operation);
if ((!world || !hasUsableWorldAssets(world)) && worldId) {
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
method: "GET",
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
world = world
? {
...world,
...detailedWorld,
assets: detailedWorld.assets || world.assets || null,
}
: detailedWorld;
try {
const detailedWorld = await worldLabsRequest<WorldLabsWorld>(config, {
method: "GET",
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
world = world
? {
...world,
...detailedWorld,
assets: detailedWorld.assets || world.assets || null,
}
: detailedWorld;
} catch (error) {
logger.warn("world detail fetch failed, retry with running status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
worldId,
error: error instanceof Error ? error.message : "unknown",
});
}
}
if (!world) {
throw new Error("World Labs 任务已完成,但未返回 world 数据");
const pendingPayload = {
...(getObjectValue(persistedWorldAsset?.assetPayload) || {}),
providerId: config.providerId || null,
providerName: config.providerName || null,
worldId: worldId || null,
worldUrl: null,
displayName: null,
model: null,
assetsPending: true,
operationMetadata: operation.metadata ?? null,
};
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "running",
progress: 95,
assetPayload: pendingPayload,
});
logger.warn("task completed but world payload is missing", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
worldId: worldId || null,
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: 95,
output: operation.response ?? null,
worldId: worldId || null,
worldUrl: null,
previewImageUrl: null,
assetPayload: pendingPayload,
},
});
}
if (!hasUsableWorldAssets(world)) {
@ -1039,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) {
: 0;
const hasMissingSpzArchive =
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
const storageArchivedAt =
[
[rawWorldUrl, stableWorldUrl],
[rawMeshUrl, stableModelUrl],
[rawThumbnailUrl, stableThumbnailUrl],
[rawPanoUrl, stablePanoUrl],
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
Boolean(
stableSpzUrls &&
assetPayload.spzUrls &&
Object.entries(stableSpzUrls).some(
([key, value]) =>
typeof assetPayload.spzUrls?.[key] === "string" &&
assetPayload.spzUrls[key] !== value
)
)
? new Date().toISOString()
: null;
const assetsArchivingPending = Boolean(
(rawMeshUrl && !stableModelUrl) ||
(rawThumbnailUrl && !stableThumbnailUrl) ||
(rawPanoUrl && !stablePanoUrl) ||
hasMissingSpzArchive
);
const storageArchivedAt = assetsArchivingPending
? null
: new Date().toISOString();
logger.info("task success status", {
elapsedMs: Date.now() - startedAt,
@ -1068,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) {
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
assetsArchivingPending,
});
const responseAssetPayload = {
@ -1081,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) {
panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null,
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
assetsArchivingPending,
sourceWorldUrl: rawWorldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl

View File

@ -142,7 +142,27 @@ function shouldUseExternalTaskCreateApi(
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
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) {
@ -153,6 +173,49 @@ function resolveOfficialMarbleModel(model?: string | null) {
return normalized;
}
function buildExternalWorldTaskPayload(
mode: WorldTaskMode,
worldPrompt: Record<string, unknown>,
prompt: string | null,
displayName: string,
) {
const normalizedPrompt = readOptionalTrimmedText(prompt);
if (!normalizedPrompt && mode === "text") {
throw new Error("请输入世界描述");
}
const payload: Record<string, unknown> = {
model: WORLDLABS_EXTERNAL_TASK_MODEL,
displayName,
};
if (normalizedPrompt) {
payload.prompt = normalizedPrompt;
} else if (mode !== "text") {
payload.prompt = "图生世界";
}
if (mode === "image" || mode === "multi-image") {
payload.world_prompt = worldPrompt;
}
return payload;
}
function buildOfficialWorldTaskPayload(
displayName: string,
worldPrompt: Record<string, unknown>,
officialMarbleModel: string,
body: CreateWorldTaskRequest
) {
return {
display_name: displayName,
model: officialMarbleModel,
permission: {
public: body.makePublic === true,
},
world_prompt: worldPrompt,
};
}
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const textPrompt = readOptionalTrimmedText(body.prompt);
@ -356,41 +419,73 @@ export async function POST(request: Request) {
for (const candidate of candidates) {
try {
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
assertWorldModeSupportedByCandidate(candidate, mode);
let requestUsedExternalTaskApi = false;
if (useExternalTaskApi) {
const prompt = readOptionalTrimmedText(body.prompt);
if (!prompt) {
throw new Error("请输入世界描述");
const payload = buildExternalWorldTaskPayload(
mode,
worldPrompt,
readOptionalTrimmedText(body.prompt),
displayName,
);
try {
const externalResult = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
>(candidate, {
method: "POST",
path: "/api/external/task/generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: payload,
});
result = normalizeExternalTaskCreateOperation(externalResult);
requestUsedExternalTaskApi = true;
} catch (error) {
const externalError = error instanceof Error ? error.message : String(error);
logger.warn("external world create failed, fallback to marble endpoint", {
providerId: candidate.providerId || null,
error: externalError,
});
if (isCredentialOrBillingProviderError(error)) {
await reportWorldLabsProviderRuntimeFailure(
candidate.providerId,
externalError
);
}
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST",
path: "/marble/v1/worlds:generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: buildOfficialWorldTaskPayload(
displayName,
worldPrompt,
officialMarbleModel,
body
),
});
requestUsedExternalTaskApi = false;
}
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 {
requestUsedExternalTaskApi = false;
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST",
path: "/marble/v1/worlds:generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: {
display_name: displayName,
model: officialMarbleModel,
permission: {
public: body.makePublic === true,
},
world_prompt: worldPrompt,
},
json: buildOfficialWorldTaskPayload(
displayName,
worldPrompt,
officialMarbleModel,
body
),
});
}
if (!result?.operation_id) {
throw new Error("创建 World Labs 世界任务返回缺少 operation_id");
}
usedProvider = candidate;
usedExternalTaskApi = useExternalTaskApi;
usedExternalTaskApi = requestUsedExternalTaskApi;
providerForLog = candidate.providerId || null;
break;
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@ -1,234 +1,10 @@
import { type NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { ExtendServiceError, executeExtendJob } from "@/lib/ai/extend/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import {
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
isRateLimitError,
} from "@/lib/request-error";
import { executeExtendPOST } from "@/lib/canvas/operations/extend";
export const runtime = "nodejs";
export const maxDuration = 180;
function parseIncludeBase64(value: unknown): boolean {
if (typeof value === "boolean") return value;
const normalized = String(value ?? "").trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
}
function buildExtendSuccessPayload(result: Awaited<ReturnType<typeof executeExtendJob>>) {
return {
success: true,
original_url: result.original_url,
thumbnail_url: result.thumbnail_url,
...(result.image_base64 ? { base64: result.image_base64 } : {}),
processing_time: result.processing_time,
};
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const contentType = request.headers.get("content-type") || "";
let imageUrl: string | null = null;
let imageBase64: string | null = null;
let prompt = "图像拓展处理";
let chatId: string | null = null;
let messageId: string | null = null;
let includeBase64 = false;
const requestId = `extend-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
if (contentType.includes("application/json")) {
const body = await request.json();
imageUrl = body.image_url ? String(body.image_url).trim() : null;
imageBase64 = body.image_base64 ? String(body.image_base64).trim() : null;
prompt = body.prompt || prompt;
chatId = body.chatId || null;
messageId = body.messageId || null;
includeBase64 = parseIncludeBase64(body.includeBase64);
if (!imageUrl && !imageBase64) {
return NextResponse.json(
{ error: "缺少image_base64或image_url参数" },
{ status: 400 },
);
}
console.log("[Extend API] Request received:", {
requestId,
source: imageUrl ? "url" : "base64",
includeBase64,
chatId,
messageId,
xScale: body.xScale,
yScale: body.yScale,
});
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
reason: "image_extend",
modelId: "image-extend",
note: "图片扩图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeExtendJob({
userId: session.user.id,
imageUrl,
imageBase64,
prompt,
chatId,
messageId,
xScale: body.xScale ? Number(body.xScale) : undefined,
yScale: body.yScale ? Number(body.yScale) : undefined,
includeBase64,
requestId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume extend points:", pointsError);
}
}
console.log("[Extend API] Request completed:", {
requestId,
includeBase64,
processingTime: result.processing_time,
originalUrl: result.original_url.slice(0, 120),
});
return NextResponse.json(buildExtendSuccessPayload(result));
}
if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const multipartChatId = String(formData.get("chatId") || "") || null;
const multipartMessageId = String(formData.get("messageId") || "") || null;
includeBase64 = parseIncludeBase64(formData.get("includeBase64"));
console.log("[Extend API] Multipart request received:", {
requestId,
includeBase64,
chatId: multipartChatId,
messageId: multipartMessageId,
bytes: buffer.length,
});
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "extend"),
reason: "image_extend",
modelId: "image-extend",
note: "图片扩图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeExtendJob({
userId: session.user.id,
imageBase64: buffer.toString("base64"),
prompt: String(formData.get("prompt") || prompt),
chatId: multipartChatId,
messageId: multipartMessageId,
includeBase64,
requestId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume extend points:", pointsError);
}
}
console.log("[Extend API] Multipart request completed:", {
requestId,
includeBase64,
processingTime: result.processing_time,
originalUrl: result.original_url.slice(0, 120),
});
return NextResponse.json(buildExtendSuccessPayload(result));
}
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
{ status: 400 },
);
} catch (error) {
if (error instanceof ExtendServiceError) {
if (error.status === 429 || isRateLimitError(error)) {
return NextResponse.json(
{
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
detail: error.detail || error.message,
retryAfter: 10,
},
{
status: 429,
headers: {
"Retry-After": "10",
},
},
);
}
return NextResponse.json(
{
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
detail: error.detail || error.message,
},
{ status: error.status },
);
}
console.error("[Extend API] 错误:", error);
if (isRateLimitError(error)) {
return NextResponse.json(
{
error: getFriendlyRateLimitMessage("当前操作过于频繁,请稍后再试"),
detail: error instanceof Error ? error.message : String(error),
retryAfter: 10,
},
{
status: 429,
headers: {
"Retry-After": "10",
},
},
);
}
return NextResponse.json(
{
error: getUserFacingRequestErrorMessage(error, "图像拓展失败"),
detail: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
return executeExtendPOST(request, session.user.id);
}

View File

@ -1,4 +1,8 @@
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
import type {
FlowBoardDraft,
FlowBoardSheetSnapshotData,
FlowBoardSnapshotData,
} from "@/lib/db/schema";
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@ -8,6 +12,73 @@ function normalizeRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
const DEFAULT_FLOW_BOARD_CANVAS = {
backgroundColor: "#f7f8fb",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
};
function normalizeCanvasColor(value: unknown, fallback: string) {
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)
? value.toLowerCase()
: fallback;
}
function normalizeFlowBoardCanvas(value: unknown): Record<string, unknown> {
const canvas = isRecord(value) ? value : {};
const requestedGridSize =
typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize)
? Math.round(canvas.gridSize)
: DEFAULT_FLOW_BOARD_CANVAS.gridSize;
return {
backgroundColor: normalizeCanvasColor(
canvas.backgroundColor,
DEFAULT_FLOW_BOARD_CANVAS.backgroundColor
),
gridColor: normalizeCanvasColor(
canvas.gridColor,
DEFAULT_FLOW_BOARD_CANVAS.gridColor
),
gridEnabled:
typeof canvas.gridEnabled === "boolean"
? canvas.gridEnabled
: DEFAULT_FLOW_BOARD_CANVAS.gridEnabled,
gridSize: Math.min(64, Math.max(8, requestedGridSize)),
};
}
function normalizeSheetId(value: unknown, index: number) {
const id = typeof value === "string" ? value.trim().slice(0, 128) : "";
return id || `sheet-${index + 1}`;
}
function normalizeSheetTitle(value: unknown, index: number) {
const title = typeof value === "string" ? value.trim().slice(0, 128) : "";
return title || `画布 ${index + 1}`;
}
function normalizeFlowBoardSheet(
value: unknown,
index: number
): FlowBoardSheetSnapshotData | null {
if (!isRecord(value)) {
return null;
}
return {
id: normalizeSheetId(value.id, index),
title: normalizeSheetTitle(value.title, index),
children: Array.isArray(value.children)
? value.children.filter(isRecord)
: [],
viewport: normalizeRecord(value.viewport),
theme: normalizeRecord(value.theme),
canvas: normalizeFlowBoardCanvas(value.canvas),
};
}
export function normalizeFlowBoardTitle(value: unknown) {
const title = String(value || "").trim();
return title.slice(0, 256) || "未命名流程白板";
@ -17,15 +88,67 @@ export function normalizeFlowBoardSnapshot(
value: unknown
): FlowBoardSnapshotData {
const raw = isRecord(value) ? value : {};
const legacyChildren = Array.isArray(raw.children)
? raw.children.filter(isRecord)
: [];
const normalizedSheets = Array.isArray(raw.sheets)
? raw.sheets
.map(normalizeFlowBoardSheet)
.filter(
(sheet): sheet is FlowBoardSheetSnapshotData => Boolean(sheet)
)
: [];
const sheets =
normalizedSheets.length > 0
? normalizedSheets
: [
{
id: "sheet-1",
title: "画布 1",
children: legacyChildren,
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
canvas: normalizeFlowBoardCanvas(raw.canvas),
},
];
const requestedActiveSheetId =
typeof raw.activeSheetId === "string" ? raw.activeSheetId.trim() : "";
const activeSheet =
sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0];
const activeChildren = Array.isArray(raw.children)
? legacyChildren
: activeSheet.children;
const activeViewport = isRecord(raw.viewport)
? raw.viewport
: activeSheet.viewport;
const activeTheme = isRecord(raw.theme) ? raw.theme : activeSheet.theme;
const activeCanvas = isRecord(raw.canvas)
? normalizeFlowBoardCanvas(raw.canvas)
: normalizeFlowBoardCanvas(activeSheet.canvas);
const nextSheets = sheets.map((sheet) =>
sheet.id === activeSheet.id
? {
...sheet,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
}
: sheet
);
const updatedAt =
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
? raw.updatedAt
: new Date().toISOString();
return {
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
version: 2,
activeSheetId: activeSheet.id,
sheets: nextSheets,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
updatedAt,
};
}

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { eq, desc, sql } from "drizzle-orm";
import { generatedImage, imageArchiveTask } from "@/lib/db/schema";
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
export async function GET(request: NextRequest) {
@ -19,12 +19,23 @@ export async function GET(request: NextRequest) {
const page = Number.parseInt(searchParams.get("page") || "1");
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
const offset = (page - 1) * pageSize;
const visibleImageFilter = and(
eq(generatedImage.userId, userId),
isNull(imageArchiveTask.id)
);
// 查询总数
const countResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(generatedImage)
.where(eq(generatedImage.userId, userId));
.leftJoin(
imageArchiveTask,
and(
eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl),
eq(imageArchiveTask.status, "abandoned")
)
)
.where(visibleImageFilter);
const total = countResult[0]?.count || 0;
@ -42,7 +53,14 @@ export async function GET(request: NextRequest) {
modelId: generatedImage.modelId, // 添加 modelId 字段
})
.from(generatedImage)
.where(eq(generatedImage.userId, userId))
.leftJoin(
imageArchiveTask,
and(
eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl),
eq(imageArchiveTask.status, "abandoned")
)
)
.where(visibleImageFilter)
.orderBy(desc(generatedImage.createdAt))
.limit(pageSize)
.offset(offset);

View 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",
},
});
}

View File

@ -14,6 +14,10 @@ import {
updateMessageById,
} from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema";
import {
buildOpenClawArtifactProxyUrl,
normalizeOpenClawArtifacts,
} from "@/lib/openclaw-chat/artifacts";
import {
getFalImageTaskStatus,
resolveFalImageTaskToMarkdown,
@ -32,12 +36,29 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
const falTaskLastPolledAt = new Map<string, number>();
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;
const metadata = value as Record<string, unknown>;
if (metadata.taskKind !== "clawd_chat") return value;
const publicMetadata = { ...metadata };
delete publicMetadata.upstreamTaskKey;
publicMetadata.artifacts = normalizeOpenClawArtifacts(metadata.artifacts).map(
(artifact, artifactIndex) => ({
...artifact,
downloadUrl: new URL(
buildOpenClawArtifactProxyUrl({
chatId: params.message.chatId,
messageId: params.message.id,
artifactIndex,
}),
params.requestOrigin
).toString(),
})
);
return publicMetadata;
}
@ -286,7 +307,11 @@ export async function GET(request: NextRequest) {
createdAt: msg.createdAt,
parts: parts,
attachments: msg.attachments || [],
videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null,
videoMetadata:
toPublicVideoMetadata({
message: msg,
requestOrigin: request.nextUrl.origin,
}) || null,
};
});

View File

@ -1,138 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
executeRmbgJob,
fetchImageBufferFromUrl,
RmbgServiceError,
} from "@/lib/ai/rmbg/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const contentType = request.headers.get("content-type") || "";
let imageBuffer: Buffer;
let imageBase64: string | undefined;
let prompt = "抠图处理";
let chatId: string | null = null;
let messageId: string | null = null;
let skipGallerySave = false;
let sourceImageUrl: string | null = null;
if (contentType.includes("application/json")) {
const body = await request.json();
prompt = body.prompt || "抠图处理";
chatId = body.chatId || null;
messageId = body.messageId || null;
skipGallerySave = body.skipGallerySave === true || body.skip_gallery_save === 1;
const imageUrl = body.imageUrl || body.image_url;
const explicitSourceImageUrl = body.sourceImageUrl || body.source_image_url;
if (imageUrl) {
sourceImageUrl = String(imageUrl);
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
} else if (body.image_base64) {
imageBase64 = String(body.image_base64);
imageBuffer = Buffer.from(imageBase64, "base64");
if (explicitSourceImageUrl) {
sourceImageUrl = String(explicitSourceImageUrl);
}
} else {
return NextResponse.json(
{ error: "缺少 imageUrl 或 image_base64 参数" },
{ status: 400 }
);
}
} else if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "缺少file参数" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
imageBuffer = Buffer.from(arrayBuffer);
imageBase64 = imageBuffer.toString("base64");
prompt = (formData.get("prompt") as string) || "抠图处理";
chatId = (formData.get("chatId") as string) || null;
messageId = (formData.get("messageId") as string) || null;
skipGallerySave =
formData.get("skipGallerySave") === "true" || formData.get("skip_gallery_save") === "1";
sourceImageUrl =
(formData.get("sourceImageUrl") as string) ||
(formData.get("source_image_url") as string) ||
null;
} else {
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json或multipart/form-data" },
{ status: 400 }
);
}
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "remove-background"),
reason: "image_remove_background",
modelId: "rmbg",
note: "图片抠图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeRmbgJob({
userId: session.user.id,
imageBuffer,
imageBase64,
prompt,
chatId,
messageId,
skipGallerySave,
sourceImageUrl,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume RMBG points:", pointsError);
}
}
return NextResponse.json({
success: true,
...result,
});
} catch (error) {
if (error instanceof RmbgServiceError) {
return NextResponse.json(
{
error: error.message,
detail: error.detail || error.message,
},
{ status: error.status }
);
}
console.error("[RMBG API] 处理错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 }
);
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
return executeRemoveBackgroundPOST(request, session.user.id);
}

View File

@ -399,7 +399,7 @@ function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
function normalizeLovartComposerPrefix(value: unknown): string {
const normalized = String(value || "").trim();
if (!normalized) {
return "让「由壹点AI」";
return "让「𝗕𝘂𝗬𝗶」";
}
return normalized.slice(0, 40);
}
@ -573,7 +573,7 @@ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
]);
const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
siteName: "AI Chatbot",
siteName: "𝗕𝘂𝗬𝗶",
siteLogo: null,
siteLogoDark: null,
favicon: null,
@ -583,11 +583,11 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
bannerActionText: "立即查看",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: "下达指令,即刻呈现",
surpriseCategories: ["全部", "精选"],
canvasDefaultModelId: null,
lovartComposerPrefix: "让「由壹点AI」",
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
lovartComposerPreviewTexts: [],
lovartPromptTagVisibility: {},
contactConfig: normalizeContactConfig(undefined),
@ -664,7 +664,7 @@ async function readScopedSiteSettings(keys: string[]) {
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
return {
siteName: String(settings?.siteName || "AI Chatbot"),
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
siteLogo: settings?.siteLogo || null,
siteLogoDark: settings?.siteLogoDark || null,
favicon: settings?.favicon || null,
@ -822,7 +822,7 @@ async function getScopedPublicSiteSettings(
usageTimeStart: typedSettings?.usageTimeStart || "08:00",
usageTimeEnd: typedSettings?.usageTimeEnd || "20:00",
usageTimeMessage:
typedSettings?.usageTimeMessage || "由壹点累了也是需要休息的",
typedSettings?.usageTimeMessage || "BuYi 累了也是需要休息的",
});
}
@ -953,9 +953,9 @@ async function getScopedPublicSiteSettings(
typedSettings?.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: typedSettings?.lovartPromoActionText || "立即升级",
lovartHeroTitle: typedSettings?.lovartHeroTitle || "由壹点AI 让设计更简单",
lovartHeroTitle: typedSettings?.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle:
typedSettings?.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
typedSettings?.lovartHeroSubtitle || "下达指令,即刻呈现",
surpriseCategories: normalizeSurpriseCategories(
valueByKey.get(HOME_SURPRISE_CATEGORIES_KEY)
),
@ -1476,7 +1476,7 @@ export async function GET(request: NextRequest) {
usageTimeEnabled: false,
usageTimeStart: "08:00",
usageTimeEnd: "20:00",
usageTimeMessage: "由壹点累了也是需要休息的",
usageTimeMessage: "BuYi 累了也是需要休息的",
bannerEnabled: true,
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: null,
@ -1525,8 +1525,8 @@ export async function GET(request: NextRequest) {
canvasDetailAgentModelId: null,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: "下达指令,即刻呈现",
surpriseCategories,
aiDetailGenerationEnabled,
lovartComposerPrefix,
@ -1546,8 +1546,8 @@ export async function GET(request: NextRequest) {
baseSettings.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级",
lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单",
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
lovartHeroTitle: baseSettings.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "下达指令,即刻呈现",
notification,
surpriseCategories,
aiDetailGenerationEnabled,
@ -3119,7 +3119,7 @@ export async function PUT(request: NextRequest) {
usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false,
usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00",
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的",
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "BuYi 累了也是需要休息的",
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
bannerLink: siteSettingsPayload.bannerLink,
@ -3144,8 +3144,8 @@ export async function PUT(request: NextRequest) {
siteSettingsPayload.lovartPromoText ??
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级",
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "由壹点AI 让设计更简单",
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "懂你的设计代理,帮你搞定一切",
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "𝗕𝘂𝗬𝗶 让设计触手可及",
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "下达指令,即刻呈现",
updatedBy: session.user.id,
})
.returning();

View 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 });
}
}

View File

@ -1,5 +1,6 @@
"use client";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import {
AlertCircle,
AlertTriangle,
@ -74,6 +75,7 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import { useCanvasImageArchives } from "@/hooks/use-canvas-image-archives";
import useSWR from "swr";
// 深色主题组件 V2 (新版布局)
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
@ -86,7 +88,9 @@ import {
CanvasAddImageDialog,
type CanvasAddImageDialogImage,
} from "@/components/canvas/CanvasAddImageDialog";
import { CanvasAgentUiListener } from "@/components/canvas/chat-panel/canvas-agent-ui-listener";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasSurfaceHeader } from "@/components/canvas/CanvasSurfaceHeader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -249,7 +253,6 @@ import type {
AgentWorkflowUiConfig,
} from "@/types/agent";
import type {
AgentSlotNodeData,
CanvasEdge,
CanvasInitialProject,
CanvasNode,
@ -262,7 +265,7 @@ import type {
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
const CANVAS_SURFACE_BACKGROUND = "#f4f4f5";
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
const fetchCanvasSiteSettings = async (url: string) => {
const separator = url.includes("?") ? "&" : "?";
@ -1906,6 +1909,8 @@ export default function CanvasEditorLegacyPage({
[isVideoMode]
);
useCanvasImageArchives(id);
// Store state
const projectTitle = useCanvasStore((state) => state.projectTitle);
const viewport = useCanvasStore(canvasSelectors.viewport);
@ -3141,8 +3146,7 @@ export default function CanvasEditorLegacyPage({
const updateWidthFromClientX = (clientX: number) => {
const currentViewportWidth = window.innerWidth;
const nextWidth =
currentViewportWidth - DOCKED_PANEL_INSET - clientX;
const nextWidth = currentViewportWidth - DOCKED_PANEL_INSET - clientX;
setFloatingPanelWidth(
clampFloatingPanelWidth(nextWidth, currentViewportWidth)
@ -5072,59 +5076,84 @@ export default function CanvasEditorLegacyPage({
workflowInputs.brand,
]);
const canvasSaveQueue = useRef<ReturnType<typeof createCanvasSaveQueue> | null>(null);
if (!canvasSaveQueue.current) canvasSaveQueue.current = createCanvasSaveQueue();
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id);
canvasPersistenceScope.current = id;
const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
const canvasData = buildPersistedCanvasData();
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
const result = await canvasService.saveCanvasState(
id,
canvasData,
activeCanvasWorkspaceType,
{
baseCanvasRevision: canvasRevision,
baseCanvasUpdatedAt: canvasUpdatedAt,
}
);
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
if (result.status === 409) {
externalCanvasConflictRef.current = true;
console.warn("[Canvas] stale save revision handled without user prompt", {
reason,
canvasUpdatedAt: result.canvasUpdatedAt,
});
if (!externalCanvasConflictNotifiedRef.current) {
externalCanvasConflictNotifiedRef.current = true;
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
(reason: "autosave" | "delete" | "beforeunload") => canvasSaveQueue.current!.enqueue(async () => {
if (canvasPersistenceScope.current !== id) return false;
setIsSaving(true);
try {
const canvasData = buildPersistedCanvasData();
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
const result = await canvasService.saveCanvasState(
id,
canvasData,
activeCanvasWorkspaceType,
{
baseCanvasRevision: canvasRevision,
baseCanvasUpdatedAt: canvasUpdatedAt,
}
);
if (canvasPersistenceScope.current !== id) return false;
if (!result.success) {
console.error(`[Canvas] ${reason} save failed:`, result.error);
if (result.status === 409) {
externalCanvasConflictRef.current = true;
console.warn("[Canvas] stale save revision handled without user prompt", {
reason,
canvasUpdatedAt: result.canvasUpdatedAt,
});
if (!externalCanvasConflictNotifiedRef.current) {
externalCanvasConflictNotifiedRef.current = true;
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
}
}
} else if (result.data?.updatedAt) {
externalCanvasConflictRef.current = false;
externalCanvasConflictNotifiedRef.current = false;
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
}
} else if (result.data?.updatedAt) {
externalCanvasConflictRef.current = false;
externalCanvasConflictNotifiedRef.current = false;
useCanvasStore
.getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
return result.success;
} finally {
if (canvasPersistenceScope.current === id) setIsSaving(false);
}
return result.success;
},
}),
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
);
const persistCanvasBeforeAgent = useCallback(() => {
// Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null;
}
return persistCanvasState("autosave");
}, [persistCanvasState]);
// 自动保存逻辑
useEffect(() => {
if (isLoading || isAgentMode || isAgentOperationRefreshActive()) return;
const saveTimer = setTimeout(async () => {
setIsSaving(true);
canvasAutosaveTimer.current = null;
if (isAgentOperationRefreshActive()) return;
try {
await persistCanvasState("autosave");
} catch (error) {
console.error("Auto save failed:", error);
}
setIsSaving(false);
}, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer);
canvasAutosaveTimer.current = saveTimer;
return () => {
clearTimeout(saveTimer);
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
};
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
useEffect(() => {
@ -7515,7 +7544,7 @@ export default function CanvasEditorLegacyPage({
materialMimeType: image.mimeType || undefined,
originalHeight: dimensions.originalHeight,
originalWidth: dimensions.originalWidth,
source: "agent-workflow-material-search",
source: image.sourceType === "upload" ? "local-upload" : "agent-workflow-material-search",
},
},
connections: [],
@ -7527,7 +7556,7 @@ export default function CanvasEditorLegacyPage({
if (createdNodeIds.length > 0) {
selectNodes(createdNodeIds);
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
toast.success(`已添加 ${createdNodeIds.length} 张${validImages.every((image) => image.sourceType === "upload") ? "图片" : "白底图"}到画布`);
}
},
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
@ -7543,64 +7572,6 @@ export default function CanvasEditorLegacyPage({
setIsAddImageDialogOpen(true);
}, [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(() => {
if (isVideoMode) {
handleAddWorkflowNode("video-input");
@ -9374,6 +9345,7 @@ export default function CanvasEditorLegacyPage({
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (useCanvasStore.getState().canvasImageSelectionSession) return;
// 如果正在输入则不触发快捷键(包括 input、textarea 和 contentEditable 元素)
if (isCanvasTextEntryTarget(e.target)) {
return;
@ -10311,7 +10283,11 @@ export default function CanvasEditorLegacyPage({
return (
<TooltipProvider>
<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={
{
"--canvas-surface-background": canvasSurfaceBackground,
@ -10319,10 +10295,12 @@ export default function CanvasEditorLegacyPage({
} as React.CSSProperties
}
>
<CanvasAgentUiListener projectId={id} workspace="canvas" />
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div
className="absolute inset-0 z-0"
data-canvas-stage="true"
style={isVideoMode ? undefined : { right: panelOcclusionRight, top: 40 }}
key={`workspace-${id}`}
>
<CanvasWorkspace
@ -10333,9 +10311,10 @@ export default function CanvasEditorLegacyPage({
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
: '点击顶部"添加"按钮开始创作'
}
occlusionRight={panelOcclusionRight}
occlusionRight={0}
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
showEmptyState={!isVideoMode}
showEmptyState={false}
showZoomControls={isVideoMode}
workspaceLabel={workspaceLabel}
workspaceVariant={isVideoMode ? "video" : "canvas"}
layerSplitEnabled={isLayerSplitEnabled}
@ -11024,14 +11003,27 @@ export default function CanvasEditorLegacyPage({
</div>
) : null}
{brandEntry}
{isVideoMode ? brandEntry : (
<CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode}
onHome={goHome}
onOpenFiles={() => {
setIsPanelOpen(true);
setPanelMode("fileList");
}}
onSave={() => void handleSave()}
projectTitle={projectTitle}
rightInset={panelOcclusionRight}
/>
)}
{agentDisabledNotice}
{/* 2. Floating Actions (Top Right) */}
{!isVideoMode && !isPanelOpen && (
<div
className="absolute z-20"
style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}
style={{ top: 46, right: 12 }}
>
<Button
className={cn(
@ -11063,7 +11055,7 @@ export default function CanvasEditorLegacyPage({
)}
data-canvas-right-panel="true"
style={{
top: DOCKED_PANEL_INSET,
top: 0,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: resolvedPanelWidth,
@ -11094,7 +11086,7 @@ export default function CanvasEditorLegacyPage({
/>
</div>
) : 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 ? (
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
) : panelMode === "chat" ? (
@ -11111,6 +11103,7 @@ export default function CanvasEditorLegacyPage({
onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode("fileList")}
onRequireLogin={openLoginDialog}
onBeforeAgentRequest={persistCanvasBeforeAgent}
selectedAgentPresetId={
isCanvasAgentModeEnabled ? selectedAgentPresetId : null
}
@ -11200,11 +11193,11 @@ export default function CanvasEditorLegacyPage({
className={cn(
isVideoMode
? 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 &&
(shouldDockFloatingToolbarToSide
? "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={
shouldDockFloatingToolbarToSide
@ -11222,7 +11215,7 @@ export default function CanvasEditorLegacyPage({
tool === "select" &&
(isVideoMode
? 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")}
size="icon"
@ -11232,6 +11225,8 @@ export default function CanvasEditorLegacyPage({
active={tool === "select"}
className="h-4 w-4"
fallback={MousePointer}
referenceName={isVideoMode ? undefined : "cursor"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"selectTool"
@ -11245,63 +11240,6 @@ export default function CanvasEditorLegacyPage({
</TooltipContent>
</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>
<TooltipTrigger asChild>
<Button
@ -11312,7 +11250,7 @@ export default function CanvasEditorLegacyPage({
tool === "pan" &&
(isVideoMode
? 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")}
size="icon"
@ -11322,6 +11260,8 @@ export default function CanvasEditorLegacyPage({
active={tool === "pan"}
className="h-4 w-4"
fallback={Hand}
referenceName={isVideoMode ? undefined : "pan"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
/>
</Button>
@ -11442,7 +11382,7 @@ export default function CanvasEditorLegacyPage({
tool === "draw" &&
(isVideoMode
? 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")}
size="icon"
@ -11483,7 +11423,7 @@ export default function CanvasEditorLegacyPage({
isAgentMode &&
(isVideoMode
? 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}
size="icon"
@ -11499,6 +11439,14 @@ export default function CanvasEditorLegacyPage({
fallback={
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
}
referenceName={
isVideoMode
? undefined
: shouldUseStyleModeButtonForStylePro
? "style-controls"
: "creativity"
}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"styleMode"
@ -11528,6 +11476,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
referenceName={isVideoMode ? undefined : "style-controls"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"stylePro"
@ -11557,6 +11507,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers}
referenceName={isVideoMode ? undefined : "layers"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"batchProcessing"
@ -11588,6 +11540,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
referenceName={isVideoMode ? undefined : "image"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addImageInput" : "addImage"
@ -11620,6 +11574,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Type}
referenceName={isVideoMode ? undefined : "text"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addScriptNode" : "addText"
@ -11777,6 +11733,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={PenTool}
referenceName={isVideoMode ? undefined : "draw"}
referenceSize={20}
item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"realtimeCanvas",
@ -11824,6 +11782,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
referenceName={isVideoMode ? undefined : "image-enhance"}
referenceSize={20}
item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"enhanceCanvas",
@ -11877,6 +11837,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Box}
referenceName={isVideoMode ? undefined : "model-auto"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
/>
</Button>
@ -11966,6 +11928,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Plus}
referenceName={isVideoMode ? undefined : "plus"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "newVideoCanvas" : "newCanvas"
@ -11993,12 +11957,15 @@ export default function CanvasEditorLegacyPage({
type="file"
/>
{/* 6. Bottom Left Compact Controls */}
{/* Compact zoom and history controls. */}
<div
data-canvas-compact-controls="true"
className="absolute z-20"
style={{
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
left: `${compactControlsLeft + 8}px`,
...(isVideoMode
? { left: `${compactControlsLeft + 8}px` }
: { right: `${panelOcclusionRight + 16}px` }),
}}
>
<div

View File

@ -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) {
if (!task || task.status !== "success") return false;
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
@ -850,9 +906,21 @@ export default function CanvasModel3DPage() {
);
const pollAttemptsRef = useRef(0);
const pollErrorCountRef = useRef(0);
const multiViewSlotsRef = 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
const { data: siteSettingsData } = useSWR(
"/api/site-settings?scope=branding",
@ -1114,7 +1182,9 @@ export default function CanvasModel3DPage() {
modelVersion: task.modelVersion,
progress: task.progress ?? null,
modelUrl: task.modelUrl ?? null,
previewImageUrl: task.previewImageUrl ?? null,
previewImageUrl: isPersistableTaskPreviewUrl(task.previewImageUrl)
? task.previewImageUrl
: null,
error: task.error ?? null,
sourceTaskId: task.sourceTaskId ?? null,
exportFormat: task.exportFormat ?? null,
@ -1434,6 +1504,33 @@ export default function CanvasModel3DPage() {
});
if (!result.success || !result.data) {
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 = {
...currentTask,
status: "failed",
@ -1479,6 +1576,8 @@ export default function CanvasModel3DPage() {
error:
status === "failed" ? result.data.errorMessage || "任务失败" : null,
};
pollErrorCountRef.current = 0;
if (taskRecordChanged(currentTask, nextRecord)) {
setCurrentTask(nextRecord);
upsertTaskHistory(nextRecord);
@ -1652,6 +1751,7 @@ export default function CanvasModel3DPage() {
sourceTaskId?: string | null;
exportFormat?: string | null;
assetPayload?: Model3DTaskAssetPayload | null;
previewImageUrl?: string | null;
}) => {
const normalizedPayload =
params.provider === "tripo-3d" &&
@ -1706,6 +1806,7 @@ export default function CanvasModel3DPage() {
createdAt: Date.now(),
sourceTaskId: params.sourceTaskId || null,
exportFormat: params.exportFormat || null,
previewImageUrl: params.previewImageUrl || null,
assetPayload:
params.provider === "worldlabs-3d"
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
@ -1821,6 +1922,7 @@ export default function CanvasModel3DPage() {
providerId: uploadResult.data.providerId || null,
providerName: uploadResult.data.providerName || null,
},
previewImageUrl: referencePreviewUrl,
});
toast.success("已提交图生3D任务");
return;
@ -1855,6 +1957,8 @@ export default function CanvasModel3DPage() {
providerId: uploadResult.data.providerId || null,
providerName: uploadResult.data.providerName || null,
},
previewImageUrl:
batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null,
});
successCount += 1;
}
@ -1910,6 +2014,7 @@ export default function CanvasModel3DPage() {
providerId: boundTripoProviderId,
providerName: boundTripoProviderName,
},
previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null,
});
toast.success("已提交多视图3D任务");
} catch (error) {
@ -1992,6 +2097,7 @@ export default function CanvasModel3DPage() {
mode: "world-image",
promptText: normalizedPrompt || "图生世界",
modelLabel: worldModel,
previewImageUrl: sourceUrl || worldImagePreviewUrl,
});
toast.success("已提交图生世界任务");
return;
@ -2076,6 +2182,10 @@ export default function CanvasModel3DPage() {
mode: "world-multi-image",
promptText: normalizedPrompt || "多图世界",
modelLabel: worldModel,
previewImageUrl:
mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl ||
selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl ||
null,
});
toast.success("已提交多图世界任务");
} catch (error) {
@ -4177,8 +4287,6 @@ export default function CanvasModel3DPage() {
const canImportWorld =
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
return (
<div
className={cn(
@ -4195,17 +4303,7 @@ export default function CanvasModel3DPage() {
onClick={() => handleSelectTask(task)}
type="button"
>
{previewUrl ? (
<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>
)}
<TaskAssetPreview task={task} />
</button>
<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">

View 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"
/>
);
}

View File

@ -3,6 +3,9 @@
/* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js';
@source '../components/canvas/**/*.tsx';
@source not '../.next*/**';
@source not '../.qa/**';
@source not '../services/pascal-editor';
/* custom variant for setting dark mode programmatically */
@ -405,6 +408,15 @@
}
/* 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 {
font-size: 13px;
line-height: 1.45;
@ -507,3 +519,301 @@
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; }
/* Adapt to the resizable chat panel, independently of the browser width. */
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] { container: canvas-composer / inline-size; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer { gap: 4px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer > div:first-child { flex: 0 0 auto; overflow: visible; padding-right: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-footer > div:last-child { flex: 0 1 auto; min-width: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-mode-selector { width: auto; max-width: none; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params { width: auto; min-width: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params-label { overflow: visible; text-overflow: clip; white-space: nowrap; }
.lovart-image-params-icon { display: none; }
@container canvas-composer (max-width: 480px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-composer-mode-selector { width: 32px; min-width: 32px; padding: 0; }
.lovart-composer-mode-selector .lovart-composer-mode-label,
.lovart-composer-mode-selector [data-reference-icon="chevron-down"] { display: none; }
.lovart-composer-mode-selector > span > :first-child { margin-right: 0; }
}
@container canvas-composer (max-width: 360px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-model-selector { flex: 0 0 32px; width: 32px; padding: 0; justify-content: center; }
.lovart-model-selector > span:not([data-reference-icon]),
.lovart-model-selector > [data-reference-icon="chevron-down"] { display: none; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 0; }
}
@container canvas-composer (max-width: 480px) {
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-responsive-composer="true"] .lovart-image-params { width: 32px; min-width: 32px; padding: 0; justify-content: center; }
.lovart-image-params .lovart-image-params-icon { display: block; }
.lovart-image-params .lovart-image-params-label,
.lovart-image-params > [data-reference-icon="chevron-down"] { display: none; }
}
/* 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%); }

View File

@ -94,18 +94,33 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
}
}
function safeRemoveSessionStorage(key) {
try {
sessionStorage.removeItem(key);
return true;
} catch (e) {
return false;
}
}
var RECOVERY_KEY = 'chunk-reload-attempted-v2';
// Keep one attempt per route for the browser session. A cooldown alone can
// turn a recurring editor exception into an endless reload cycle.
var RECOVERY_KEY = 'chunk-reload-attempted-v3:' + window.location.pathname;
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
var recoveryNoticeShown = false;
function isFlowBoardEditing() {
var path = window.location.pathname;
return path === '/flow-board' || path.indexOf('/flow-board/') === 0;
}
function showManualRecoveryNotice() {
if (recoveryNoticeShown) return;
recoveryNoticeShown = true;
function show() {
if (!document.body || !isFlowBoardEditing()) return;
var notice = document.createElement('div');
notice.setAttribute('role', 'alert');
notice.setAttribute('data-runtime-recovery-notice', 'true');
notice.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:calc(100vw - 32px);padding:12px 16px;border:1px solid var(--border,#e4e4e7);border-radius:12px;background:var(--background,#fff);color:var(--foreground,#27272a);box-shadow:0 4px 20px #0002;font:14px/1.5 system-ui;';
notice.textContent = '部分页面资源加载失败,请先保存白板后手动刷新页面。';
document.body.appendChild(notice);
}
if (document.body) show();
else window.addEventListener('DOMContentLoaded', show, { once: true });
}
function shouldAutoRecover(message) {
if (!message) return false;
var normalized = String(message).toLowerCase();
@ -117,28 +132,33 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
normalized.indexOf('error loading dynamically imported module') !== -1 ||
normalized.indexOf('importing a module script failed') !== -1 ||
normalized.indexOf('module script load failed') !== -1 ||
normalized.indexOf('/_next/static/chunks/') !== -1 ||
normalized.indexOf('failed to find server action') !== -1 ||
normalized.indexOf('server components render') !== -1 ||
normalized.indexOf('application error: a client-side exception') !== -1
normalized.indexOf('failed to find server action') !== -1
);
}
function triggerAutoRecover(reason) {
if (isFlowBoardEditing()) {
showManualRecoveryNotice();
return;
}
if (lastReloadAt) return;
var now = Date.now();
if (lastReloadAt && now - lastReloadAt < 30000) return;
// If storage is blocked, a reload cannot remember its attempt and must not run.
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
lastReloadAt = now;
console.warn('[RuntimeRecover] 检测到前端运行时异常,自动刷新页面...', reason || '');
safeSetSessionStorage(RECOVERY_KEY, String(now));
console.warn('[RuntimeRecover] 检测到页面资源加载失败,尝试恢复一次...', reason || '');
setTimeout(function() {
if (isFlowBoardEditing()) {
showManualRecoveryNotice();
return;
}
window.location.reload();
}, 800);
}
window.addEventListener('error', function(event) {
var message = '';
if (event && event.message) message += event.message;
if (event && event.filename) message += ' ' + event.filename;
if (event && event.error && event.error.message) {
message += ' ' + event.error.message;
}
@ -157,12 +177,6 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
}
});
// 页面稳定一段时间后清除标记,避免旧资源异常无限刷新。
window.addEventListener('load', function() {
setTimeout(function() {
safeRemoveSessionStorage(RECOVERY_KEY);
}, 120000);
});
})();`;
export default function RootLayout({

View 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>;
}

View 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>;
}

View 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>
);
}

View File

@ -1,6 +1,8 @@
"use client";
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 {
Fragment,
@ -26,6 +28,7 @@ import {
Download,
GitCompareArrows,
ImagePlus,
LayoutTemplate,
Loader2,
Maximize2,
Sparkles,
@ -37,6 +40,10 @@ import {
Settings2,
} from "lucide-react";
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 { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
import {
@ -64,6 +71,10 @@ import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-p
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
import {
AgentAiTemplateDialog,
type AgentAiTemplateResult,
} from "@/components/agents/AgentAiTemplateDialog";
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import {
@ -300,7 +311,7 @@ const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000;
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
const MAX_AGENT_MODE_QUERY_LENGTH = 64;
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 DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025;
const DETAIL_SCALE_TARGET_MIN_PERCENT = -80;
@ -579,33 +590,7 @@ function areAttachmentValuesEqual(
);
}
function mergePersistedHistoryMessages(
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;
}
const mergePersistedHistoryMessages = mergeGenerationHistory;
function getAttachmentIdentityKey(
attachment:
@ -662,9 +647,10 @@ const AGENT_DEFERRED_SECTION_STYLE = {
containIntrinsicSize: "1px 760px",
} as CSSProperties;
const AGENT_DEFERRED_RESULTS_STYLE = {
contentVisibility: "auto",
containIntrinsicSize: "1px 900px",
// Dynamic result content must stay painted while scrolling. Deferring this subtree
// makes Chromium briefly discard image and animation layers at viewport boundaries.
const AGENT_STABLE_RESULTS_STYLE = {
contentVisibility: "visible",
} as CSSProperties;
const AGENT_DEFERRED_LIST_CARD_STYLE = {
@ -672,6 +658,10 @@ const AGENT_DEFERRED_LIST_CARD_STYLE = {
containIntrinsicSize: "1px 320px",
} as CSSProperties;
const AGENT_STABLE_RESULT_CARD_STYLE = {
contentVisibility: "visible",
} as CSSProperties;
function handlePreviewImageLoadError(
event: SyntheticEvent<HTMLImageElement, Event>,
originalUrl: string
@ -709,7 +699,7 @@ function normalizeAssistantText(value: string) {
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\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, "")
.trim();
}
@ -832,7 +822,7 @@ async function uploadDetailLocalEditReferenceImage(
throw new Error("请上传图片文件");
}
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();
@ -2574,10 +2564,24 @@ interface LocalGenerationPendingState {
modeType: AgentGenerationResultModeType;
expectedCount: number;
userTurnCountAtStart: number;
requestMessageId?: string | null;
backendTaskId?: string | null;
timestamp: number;
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_EXPIRY_MS = 30 * 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,
expectedCount: Number(record.expectedCount),
userTurnCountAtStart: Number(record.userTurnCountAtStart),
requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null,
backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null,
timestamp: Number(record.timestamp),
requestedCardIds: Array.isArray(record.requestedCardIds)
? record.requestedCardIds
@ -2660,16 +2666,6 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
}
}
const DETAIL_RESULT_PROCESSING_STAGES = [
"解码图像",
"定位选区",
"分离主体",
"重绘背景",
"融合边缘",
"检查细节",
"输出结果",
];
function DetailResultProcessingScanOverlay({
imageUrl,
label,
@ -2677,55 +2673,43 @@ function DetailResultProcessingScanOverlay({
imageUrl?: 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 (
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
<style>{`
@keyframes detailResultScanBreatheBlur {
0%, 100% { filter: blur(14px) brightness(0.8) saturate(0.9); transform: scale(1.08); }
50% { filter: blur(17px) brightness(0.9) saturate(0.98); transform: scale(1.1); }
@keyframes detailResultScanBreathe {
0%, 100% { opacity: 0.68; transform: translate3d(0, 0, 0) scale(1.06); }
50% { opacity: 0.8; transform: translate3d(0, 0, 0) scale(1.075); }
}
@keyframes detailResultScanSweep {
0% { top: -7%; opacity: 0; }
0% { opacity: 0; transform: translate3d(0, -100%, 0); }
8% { opacity: 1; }
92% { opacity: 1; }
100% { top: 107%; opacity: 0; }
100% { opacity: 0; transform: translate3d(0, 0, 0); }
}
@keyframes detailResultScanPulseRing {
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); }
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>
{isHttpImageUrl(imageUrl) ? (
<img
<AgentPreviewImage
alt=""
aria-hidden
data-detail-result-scan-animation
className="absolute inset-0 h-full w-full object-cover"
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
@ -2747,8 +2731,12 @@ function DetailResultProcessingScanOverlay({
/>
<div
aria-hidden
className="absolute inset-x-0 top-0 z-40 h-px pointer-events-none"
style={{ animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite" }}
data-detail-result-scan-animation
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
className="absolute inset-x-0 bottom-0 h-[110px]"
@ -2759,7 +2747,7 @@ function DetailResultProcessingScanOverlay({
}}
/>
<div
className="absolute inset-x-0 top-0 h-[38px]"
className="absolute inset-x-0 -bottom-[38px] h-[38px]"
style={{
background:
"linear-gradient(0deg, transparent, rgba(255,255,255,0.1))",
@ -2769,15 +2757,23 @@ function DetailResultProcessingScanOverlay({
</div>
<div
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={{
animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite",
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)",
willChange: "opacity, transform",
}}
/>
>
<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
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={{
@ -2790,26 +2786,6 @@ function DetailResultProcessingScanOverlay({
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
/>
<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>
);
@ -3152,6 +3128,8 @@ export function AgentGenerationWorkspaceDarkV2({
const lastSyncedDetailModelSlotKeyRef = useRef<string | null>(null);
const detailGenerationCancelRef = useRef(false);
const workspaceStateSignatureRef = useRef<string>("");
const detailBatchSubmittingRef = useRef(false);
const reportedDetailBatchFailureRef = useRef<string | null>(null);
const generationRequestSnapshotRef = useRef<{
hiddenPrompt: string | null;
replacementExecutionCardIds: string[] | null;
@ -3236,6 +3214,10 @@ export function AgentGenerationWorkspaceDarkV2({
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] =
useState<AgentGenerationResultModeType>("scene_generation");
const [aiTemplateDialogSourceUrl, setAiTemplateDialogSourceUrl] =
useState<string | null>(null);
const [aiTemplateProcessingSourceUrl, setAiTemplateProcessingSourceUrl] =
useState<string | null>(null);
const [batchDownloadState, setBatchDownloadState] =
useState<null | BatchDownloadScope>(null);
const [batchDownloadDialog, setBatchDownloadDialog] =
@ -3388,7 +3370,7 @@ export function AgentGenerationWorkspaceDarkV2({
}
}, [closePsdTemplateDialog, isPsdTemplateDialogOpen, isPsdTemplateEnabled]);
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
const { data: historyData, mutate: refreshHistory } = useSWR<{ messages: ChatMessage[] }>(
rawChatId ? `/api/messages?chatId=${rawChatId}` : null,
fetcher,
{
@ -3482,6 +3464,75 @@ export function AgentGenerationWorkspaceDarkV2({
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
const isReplacementPreset =
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 =
currentResultModeType === "detail_replacement" &&
detailReplacementStrategy === "product_only";
@ -4129,6 +4180,16 @@ export function AgentGenerationWorkspaceDarkV2({
} = useChat<ChatMessage>({
id: chatId,
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,
generateId: generateUUID,
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(() => {
if (!historyMessages.length) {
return;
@ -4482,8 +4557,7 @@ export function AgentGenerationWorkspaceDarkV2({
if (!hasLikelyGenerationErrorText(text)) {
return false;
}
const imageUrls = extractImageUrlsFromText(text);
return imageUrls.length === 0;
return true;
}),
[assistantMessagesAfterLastUser]
);
@ -4791,6 +4865,12 @@ export function AgentGenerationWorkspaceDarkV2({
: currentResultModeType === "sku_replacement"
? skuReplacementResultUrls
: 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 pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) {
@ -4812,13 +4892,14 @@ export function AgentGenerationWorkspaceDarkV2({
return detailGenerationRunResults.length;
}
return latestReplacementResults.length;
return replacementRunRecovery.completedCardIds.length;
}, [
currentSceneGeneratedUrls.length,
detailGenerationRunResults.length,
latestReplacementResults.length,
pendingGeneration,
userMessageCount,
replacementRunRecovery.completedCardIds.length,
]);
const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration);
const isSceneResultModeGenerating =
@ -4948,21 +5029,33 @@ export function AgentGenerationWorkspaceDarkV2({
Array.isArray(pendingGeneration.requestedCardIds)
? pendingGeneration.requestedCardIds.length
: 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,
resultUrl,
resultText,
generatedCount: pendingGeneration?.modeType === "detail_replacement"
? latestReplacementResults.length
: 0,
isGenerating: isDetailResultModeGenerating,
isGenerating: isDetailResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id),
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
}),
};
}),
[
detailReplacementReferenceCards,
detailBatchTask,
isDetailResultModeGenerating,
replacementRunRecovery.completedCardIds,
latestReplacementResults,
normalizedDeletedResultCollections.detail_replacement,
pendingGeneration?.modeType,
@ -5014,13 +5107,14 @@ export function AgentGenerationWorkspaceDarkV2({
generatedCount: pendingGeneration?.modeType === "sku_replacement"
? latestReplacementResults.length
: 0,
isGenerating: isSkuResultModeGenerating,
isGenerating: isSkuResultModeGenerating && !replacementRunRecovery.completedCardIds.includes(card.id),
activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null,
}),
};
}),
[
isSkuResultModeGenerating,
replacementRunRecovery.completedCardIds,
latestReplacementResults,
normalizedDeletedResultCollections.sku_replacement,
pendingGeneration?.modeType,
@ -5114,7 +5208,7 @@ export function AgentGenerationWorkspaceDarkV2({
const detailRemainingCount = useMemo(
() =>
detailResultCards.filter(
(card) => !["completed", "passthrough", "fallback"].includes(card.status)
(card) => !["completed", "passthrough"].includes(card.status)
).length,
[detailResultCards]
);
@ -5389,7 +5483,15 @@ export function AgentGenerationWorkspaceDarkV2({
}, [currentResultModeType]);
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;
}
@ -5399,7 +5501,7 @@ export function AgentGenerationWorkspaceDarkV2({
}, [pendingGeneration, pendingGenerationProgressCount]);
useEffect(() => {
if (!pendingGeneration || !chatError) {
if (!pendingGeneration || pendingGeneration.backendTaskId || !chatError) {
return;
}
@ -5414,7 +5516,7 @@ export function AgentGenerationWorkspaceDarkV2({
}, [chatError, clearError, pendingGeneration]);
useEffect(() => {
if (!pendingGeneration) {
if (!pendingGeneration || pendingGeneration.backendTaskId) {
return;
}
@ -6140,7 +6242,6 @@ export function AgentGenerationWorkspaceDarkV2({
const processedStatuses = new Set<ReplacementCardStatus>([
"completed",
"passthrough",
"fallback",
]);
const remainingIds = resultCards
.filter((card) => !card.passthrough && !processedStatuses.has(card.status))
@ -6246,6 +6347,86 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error("当前风格化绑定的模型不存在或已停用,请重新编辑风格化模型配置");
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 sanitizedAttachments = sceneMessageAttachments;
if (
@ -6344,8 +6525,10 @@ export function AgentGenerationWorkspaceDarkV2({
);
setActiveResultModeType(currentResultModeType);
setSelectedResultUrl(null);
const requestMessageId = generateUUID();
setPendingGeneration({
chatId,
requestMessageId,
modeType: currentResultModeType,
expectedCount:
currentResultModeType === "scene_generation"
@ -6366,7 +6549,7 @@ export function AgentGenerationWorkspaceDarkV2({
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
}
try {
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
void Promise.resolve(sendMessage({ id: requestMessageId, role: "user", parts })).catch((error) => {
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
@ -6393,6 +6576,8 @@ export function AgentGenerationWorkspaceDarkV2({
detailResultCards,
detailReplacementReferenceCards.length,
detailReplacementStrategy,
detailReplacementProductSourceImages,
detailReplacementModelSourceImage,
handleRunAiDetailGeneration,
currentResultModeType,
getReplacementExecutionCards,
@ -6423,6 +6608,10 @@ export function AgentGenerationWorkspaceDarkV2({
scenePrimaryReferenceCount,
sceneSecondaryReferenceCount,
selectedModelAttachment,
selectedProductAttachments,
onPersistProject,
projectId,
refreshDetailBatch,
]);
const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true;
@ -6818,6 +7007,23 @@ export function AgentGenerationWorkspaceDarkV2({
}, 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(
(params: {
cardId?: string | null;
@ -9262,7 +9468,7 @@ export function AgentGenerationWorkspaceDarkV2({
</>
)}
</Button>
{isAnyGenerationRunning ? (
{isAnyGenerationRunning && !pendingGeneration?.backendTaskId ? (
<Button
type="button"
variant="outline"
@ -9640,7 +9846,7 @@ export function AgentGenerationWorkspaceDarkV2({
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.12 }}
style={AGENT_DEFERRED_RESULTS_STYLE}
style={AGENT_STABLE_RESULTS_STYLE}
data-agent-style-section="results"
>
<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) => (
<div
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"
>
<div className="aspect-square overflow-hidden bg-black/30">
@ -9942,7 +10148,7 @@ export function AgentGenerationWorkspaceDarkV2({
return (
<div
key={card.id}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
@ -10107,7 +10313,7 @@ export function AgentGenerationWorkspaceDarkV2({
return (
<div
key={`${card.id}-result`}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
hasResult && isActive
@ -10378,7 +10584,7 @@ export function AgentGenerationWorkspaceDarkV2({
selectedProductAttachments.map((attachment, index) => (
<div
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"
>
<div className="aspect-square overflow-hidden bg-black/30">
@ -10550,7 +10756,7 @@ export function AgentGenerationWorkspaceDarkV2({
key={candidate.id}
type="button"
onClick={() => handleSelectSkuReplacementCandidate(candidate.id)}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn(
"min-w-[280px] overflow-hidden rounded-[20px] border text-left transition-colors",
isSelected
@ -10617,7 +10823,7 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementReferenceCards.map((card, index) => (
<div
key={card.id}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
@ -10746,7 +10952,7 @@ export function AgentGenerationWorkspaceDarkV2({
return (
<div
key={`${card.id}-result`}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
style={AGENT_STABLE_RESULT_CARD_STYLE}
className={cn(
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
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"
>
<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) => {
const isActive = card.url && displayedResultUrl === card.url;
const isSceneResultCard = activeResultModeType === "scene_generation";
const isDetailGenerationCard =
activeResultModeType === "detail_generation";
const isAiTemplateProcessing =
aiTemplateProcessingSourceUrl === card.url;
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
const isDetailLocalEditBusy =
detailLocalEditSubmittingId === card.id;
@ -10913,7 +11128,7 @@ export function AgentGenerationWorkspaceDarkV2({
key={card.id}
role={card.url ? "button" : undefined}
tabIndex={card.url ? 0 : -1}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
style={AGENT_STABLE_RESULT_CARD_STYLE}
onClick={() => {
if (!card.url) return;
setSelectedResultUrl(card.url);
@ -10953,36 +11168,70 @@ export function AgentGenerationWorkspaceDarkV2({
/>
{isSceneResultCard ? (
<>
<button
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 ? (
<div className="absolute left-3 right-14 top-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
openPsdTemplateDialog({
sourceImageUrl: card.url!,
mode: getPsdTemplateModeByResultMode(activeResultModeType),
});
setSceneResizeEditorTargetUrl(card.url!);
}}
className={cn("absolute left-14 top-3 flex items-center gap-1", IMAGE_OVERLAY_ACTION_CLASS)}
aria-label="套用 PSD 模板"
className={cn(
"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" />
<span>套PSD</span>
<Maximize2 className="h-4 w-4" />
</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>
@ -11230,6 +11479,29 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
</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 ? (
<ImageAnnotator
key={`agent-scene-resize-${sceneResizeEditorTargetUrl}`}

View File

@ -112,6 +112,8 @@ const PREVIEW_DIMENSION_HANDLE_RADIUS = 3.2;
const PREVIEW_DIMENSION_HANDLE_STROKE_WIDTH = 1.2;
const PREVIEW_DIMENSION_TEXT_HALO_WIDTH = 2.2;
const PREVIEW_DIMENSION_TEXT_OUTLINE_WIDTH = 0.85;
const DIMENSION_FONT_FAMILY =
'"PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif';
const SIZE_INFO_LINE_REGEX = /尺寸信息[::]\s*([^\n]+)/i;
const NUMBER_REGEX = /\d+(?:\.\d+)?/g;
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
@ -568,87 +570,54 @@ function drawAnnotationToCanvas(params: {
context: CanvasRenderingContext2D;
annotation: DimensionAnnotation;
imageSize: ImageSize;
previewRect: PreviewRect;
annotations: DimensionAnnotation[];
autoDimensionValues?: AutoDimensionValues | null;
}) {
const { context, annotation, imageSize, annotations, autoDimensionValues } =
params;
const { context, annotation } = params;
const annotationColor = getAnnotationColor(annotation);
const label = getDimensionLabel({
annotation,
annotations,
autoDimensionValues,
});
const vector = getAnnotationVector(
annotation.x1,
annotation.y1,
annotation.x2,
annotation.y2
);
const rawAngleRadians = Math.atan2(vector.dy, vector.dx);
const angleRadians =
rawAngleRadians > Math.PI / 2
? rawAngleRadians - Math.PI
: rawAngleRadians < -Math.PI / 2
? rawAngleRadians + Math.PI
: rawAngleRadians;
const strokeWidth = clamp(
Math.min(imageSize.width, imageSize.height) * 0.001_35,
1,
2.4
);
const capLength = strokeWidth * 5.3;
const fontSize = clamp(
Math.round(Math.min(imageSize.width, imageSize.height) * 0.0176),
11,
27
);
const labelOffset = fontSize * 0.95;
const centerX = (annotation.x1 + annotation.x2) / 2 + vector.nx * labelOffset;
const centerY = (annotation.y1 + annotation.y2) / 2 + vector.ny * labelOffset;
const geometry = getPreviewAnnotationGeometry(params);
const textOutlineColor = getAnnotationTextOutlineColor(annotation);
const textOutlineWidth = annotation.glow
? Math.max(1.5, fontSize * 0.18)
: Math.max(0.85, fontSize * 0.08);
context.save();
context.strokeStyle = annotationColor;
context.lineWidth = strokeWidth;
context.lineWidth = PREVIEW_DIMENSION_STROKE_WIDTH;
context.lineCap = "round";
context.lineJoin = "round";
context.shadowColor = annotation.glow
? colorWithAlpha(annotationColor, 0.52)
? colorWithAlpha(annotationColor, 0.42)
: "transparent";
context.shadowBlur = annotation.glow ? Math.max(8, fontSize * 0.7) : 0;
context.shadowBlur = annotation.glow ? 4 : 0;
context.beginPath();
context.moveTo(annotation.x1, annotation.y1);
context.lineTo(annotation.x2, annotation.y2);
context.moveTo(geometry.start.x, geometry.start.y);
context.lineTo(geometry.end.x, geometry.end.y);
context.stroke();
context.lineWidth = PREVIEW_DIMENSION_CAP_STROKE_WIDTH;
drawDimensionCap({
context,
point: { x: annotation.x1, y: annotation.y1 },
size: capLength,
vector,
point: geometry.start,
size: PREVIEW_DIMENSION_TICK_LENGTH,
vector: geometry.vector,
});
drawDimensionCap({
context,
point: { x: annotation.x2, y: annotation.y2 },
size: capLength,
vector,
point: geometry.end,
size: PREVIEW_DIMENSION_TICK_LENGTH,
vector: geometry.vector,
});
context.translate(centerX, centerY);
context.rotate(angleRadians);
context.font = `500 ${fontSize}px "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif`;
context.translate(geometry.labelCenterX, geometry.labelCenterY);
context.rotate((geometry.angleDegrees * Math.PI) / 180);
context.font = `500 ${PREVIEW_DIMENSION_FONT_SIZE}px ${DIMENSION_FONT_FAMILY}`;
context.textAlign = "center";
context.textBaseline = "middle";
context.lineWidth = textOutlineWidth;
context.lineWidth = getAnnotationTextOutlineWidth(annotation);
context.strokeStyle = textOutlineColor;
context.strokeText(label, 0, 0);
context.strokeText(geometry.label, 0, 0.5);
context.fillStyle = annotationColor;
context.fillText(label, 0, 0);
context.fillText(geometry.label, 0, 0.5);
context.restore();
}
@ -1067,6 +1036,10 @@ export function AgentPsdTemplateExportPreview({
return;
}
if (!imageSize || !previewRect) {
throw new Error("请等待预览图片加载完成后再下载");
}
const { image, dispose } = await loadImageFromCandidates([
buildImageProxyUrl(imageUrl, { delivery: "proxy" }),
imageUrl,
@ -1082,18 +1055,25 @@ export function AgentPsdTemplateExportPreview({
}
context.drawImage(image, 0, 0, canvas.width, canvas.height);
// Draw in the same coordinates as the SVG, then map its image area to
// the original bitmap. The preview can use a smaller image variant.
context.save();
context.scale(
canvas.width / previewRect.width,
canvas.height / previewRect.height
);
context.translate(-previewRect.left, -previewRect.top);
for (const annotation of annotations) {
drawAnnotationToCanvas({
annotation,
annotations,
autoDimensionValues,
context,
imageSize: {
width: canvas.width,
height: canvas.height,
},
imageSize,
previewRect,
});
}
context.restore();
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
@ -1125,6 +1105,8 @@ export function AgentPsdTemplateExportPreview({
downloadName,
enableDimensionAnnotations,
imageUrl,
imageSize,
previewRect,
]);
return (
@ -1305,6 +1287,7 @@ export function AgentPsdTemplateExportPreview({
<text
dominantBaseline="middle"
fill={annotationColor}
fontFamily={DIMENSION_FONT_FAMILY}
fontSize={PREVIEW_DIMENSION_FONT_SIZE}
fontWeight={500}
paintOrder="stroke"

View File

@ -1,7 +1,11 @@
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";
const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES;
export interface UploadedDetailReferenceImage {
url: string;
@ -64,7 +68,7 @@ export async function uploadDetailReferenceImageFile(
throw new Error("请上传图片文件");
}
if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) {
throw new Error("目标详情图过大,请压缩到 50MB 以内");
throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
}
const dimensionsPromise = readLocalImageDimensions(file);

259
components/bloub-avatar.tsx Normal file
View 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>
);
}

View File

@ -46,7 +46,10 @@ import {
type AiWashControlLevel,
type AiWashPreset,
} 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 { cn } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas";
@ -1019,7 +1022,7 @@ export function AiWashWorkspace({
});
toast.success("图片已开始下载");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
toast.error(getCanvasImageDownloadErrorMessage(error));
} finally {
setDownloadingResultId((current) =>
current === downloadId ? null : current

View 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>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import {
@ -21,6 +21,8 @@ import {
SelectValue,
} from "@/components/ui/select";
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 {
AgentWorkflowMaterialImage,
AgentWorkflowMaterialQueryType,
@ -163,6 +165,8 @@ export function CanvasAddImageDialog({
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
const [isSearching, setIsSearching] = 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 images = useMemo(() => normalizeImages(result), [result]);
@ -182,12 +186,13 @@ export function CanvasAddImageDialog({
const handleDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen && (isUploading || isAdding)) return;
if (!nextOpen) {
resetDialogState();
}
onOpenChange(nextOpen);
},
[onOpenChange, resetDialogState]
[isAdding, isUploading, onOpenChange, resetDialogState]
);
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 (
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
@ -272,15 +322,38 @@ export function CanvasAddImageDialog({
添加图片
</DialogTitle>
<DialogDescription>
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
</DialogDescription>
</DialogHeader>
<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="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
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={() => {
onCreateBlankImage();
closeDialog();
@ -355,13 +428,13 @@ export function CanvasAddImageDialog({
</div>
{!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>
) : null}
</div>
{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}
</div>
) : null}
@ -440,6 +513,7 @@ export function CanvasAddImageDialog({
<DialogFooter className="border-t px-5 py-3">
<Button
disabled={isUploading || isAdding}
onClick={closeDialog}
type="button"
variant="outline"
@ -448,7 +522,7 @@ export function CanvasAddImageDialog({
</Button>
<Button
className="gap-2"
disabled={!selectedImages.length || isAdding}
disabled={!selectedImages.length || isAdding || isUploading}
onClick={() => void handleAddSelected()}
type="button"
>

View 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

View 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>
);
}

View 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>
);
}

View 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}
</>
);
}

View 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>
);
}

View File

@ -1,6 +1,10 @@
"use client";
import type { CSSProperties } from "react";
import {
ReferenceIcon,
type ReferenceIconName,
} from "@/components/lovart/reference-icon";
import type { LucideIcon } from "lucide-react";
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils";
@ -12,13 +16,12 @@ type CanvasToolbarIconProps = {
className?: string;
style?: CSSProperties;
active?: boolean;
referenceName?: ReferenceIconName;
referenceSize?: number;
};
function isSvgIconUrl(url: string) {
return (
/^data:image\/svg\+xml/i.test(url) ||
/\.svg(?:[?#].*)?$/i.test(url)
);
return /^data:image\/svg\+xml/i.test(url) || /\.svg(?:[?#].*)?$/i.test(url);
}
function stripImageFilterClasses(className?: string) {
@ -42,6 +45,8 @@ export function CanvasToolbarIcon({
className,
style,
active = false,
referenceName,
referenceSize = 18,
}: CanvasToolbarIconProps) {
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
@ -75,7 +80,9 @@ export function CanvasToolbarIcon({
aria-hidden="true"
className={cn(
"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
)}
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} />;
}

View File

@ -12,8 +12,13 @@ import { UiScreenNode } from "./nodes/UiScreenNode";
import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode";
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu";
import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit";
import { downloadCanvasSelectionZip } from "@/lib/client/canvas-zip-download";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas";
import {
@ -25,8 +30,10 @@ import {
Heart,
HelpCircle,
ImagePlus,
LayoutTemplate,
LayoutGrid,
Lightbulb,
Loader2,
Minus,
Pin,
Plus,
@ -65,6 +72,10 @@ import {
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
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 Model3DNodeModule = typeof import("./nodes/Model3DNode");
@ -385,6 +396,11 @@ export function CanvasWorkspace({
} | null;
const containerRef = 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 [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
@ -416,6 +432,9 @@ export function CanvasWorkspace({
const nodes = useCanvasStore(canvasSelectors.nodes);
const edges = useCanvasStore(canvasSelectors.edges);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const canvasImageSelectionSession = useCanvasStore(
(state) => state.canvasImageSelectionSession
);
const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool);
const lastAddedNodeId = useCanvasStore((state) => state.lastAddedNodeId);
@ -715,12 +734,17 @@ export function CanvasWorkspace({
canvasToolbarConfig,
"imageLocalEdit"
);
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
const selectedImageLayoutReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageLayoutReplace"
);
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const toggleCanvasImageSelection = useCanvasStore(
(state) => state.toggleCanvasImageSelection
);
const clearSelection = useCanvasStore((state) => state.clearSelection);
const addNode = useCanvasStore((state) => state.addNode);
const addEdge = useCanvasStore((state) => state.addEdge);
@ -1089,42 +1113,61 @@ export function CanvasWorkspace({
);
const fitAllImageNodesIntoViewport = useCallback(() => {
const imageNodes = nodes.filter(
(node) => node.type === "image" && Boolean((node.data as any)?.src)
const fit = getCanvasFitViewport(
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
containerSize, occlusionRight
);
if (imageNodes.length === 0) return;
if (containerSize.width <= 0 || containerSize.height <= 0) return;
if (fit) setViewport(fit);
}, [containerSize, nodes, occlusionRight, setViewport]);
const padding = 80;
const minX = Math.min(...imageNodes.map((node) => node.position.x));
const minY = Math.min(...imageNodes.map((node) => node.position.y));
const maxX = Math.max(
...imageNodes.map((node) => node.position.x + node.size.width)
);
const maxY = Math.max(
...imageNodes.map((node) => node.position.y + node.size.height)
);
const fitAllCanvasNodesIntoViewport = useCallback(() => {
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
if (fit) setViewport(fit);
}, [containerSize, nodes, occlusionRight, setViewport]);
const contentWidth = Math.max(1, maxX - minX + padding * 2);
const contentHeight = Math.max(1, maxY - minY + padding * 2);
const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => {
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 fitZoomX = visibleWidth / contentWidth;
const fitZoomY = containerSize.height / contentHeight;
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
const zoomCanvasFromContextMenu = useCallback((factor: number) => {
const current = useCanvasStore.getState().viewport;
const zoom = Math.max(0.1, Math.min(4, current.zoom * factor));
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;
const centerY = (minY + maxY) / 2;
const visibleCenterX = visibleWidth / 2;
const nextViewportX = visibleCenterX - centerX * targetZoom;
const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
setViewport({
x: nextViewportX,
y: nextViewportY,
zoom: targetZoom,
});
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
React.useEffect(() => {
if (isWorkflowCanvas) return;
const handleFitShortcut = (event: KeyboardEvent) => {
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return;
if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest(
'input, textarea, [contenteditable="true"], [role="textbox"]'
))) return;
const active = document.activeElement;
if (active && active !== document.body && !containerRef.current?.contains(active)
&& !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return;
event.preventDefault();
fitAllCanvasNodesIntoViewport();
setBlankContextPosition(null);
};
window.addEventListener("keydown", handleFitShortcut);
return () => window.removeEventListener("keydown", handleFitShortcut);
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
const getNodeInputAnchor = useCallback(
(node: CanvasNode): Position => ({
@ -1238,8 +1281,10 @@ export function CanvasWorkspace({
// 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return;
setAgentSlotContextMenu(null);
setBlankContextPosition(null);
if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false;
@ -1256,10 +1301,11 @@ export function CanvasWorkspace({
fitAllImageNodesIntoViewport();
}
}
}, [clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
}, [canvasImageSelectionSession, clearLatestGeneratedImageHighlights, clearSelection, fitAllImageNodesIntoViewport, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
// 处理画布平移开始
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return;
if (!isEventFromCanvasContainer(e.target)) return;
if (connectionDraft) {
@ -1275,7 +1321,7 @@ export function CanvasWorkspace({
!isPointOnNodeElement(e.target);
if (
tool === 'pan' ||
(tool === 'pan' && e.button === 0) ||
e.button === 1 ||
(e.button === 0 && e.altKey) ||
shouldPanBlankWorkflowCanvas
@ -1305,7 +1351,7 @@ export function CanvasWorkspace({
setMarqueeStart(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) => {
@ -1554,8 +1600,8 @@ export function CanvasWorkspace({
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
if (!isImageFile(file)) continue;
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
if (file.size > IMAGE_UPLOAD_MAX_BYTES) {
toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
continue;
}
@ -1638,6 +1684,105 @@ export function CanvasWorkspace({
}
}, [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(() => {
const handleWindowDrop = async (e: DragEvent) => {
if (!isFileDrag(e.dataTransfer)) return;
@ -1700,24 +1845,44 @@ export function CanvasWorkspace({
React.useEffect(() => {
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;
if (!container) return;
const activeElement = document.activeElement;
if (
activeElement instanceof HTMLElement &&
activeElement !== document.body &&
!container.contains(activeElement)
!container.contains(activeElement) && !fromContextMenu
) {
return;
}
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();
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(
files,
rect.left + rect.width / 2,
@ -1727,7 +1892,7 @@ export function CanvasWorkspace({
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [hasBlockingDialogOpen, processDroppedFiles]);
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]);
const handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) {
@ -1750,10 +1915,10 @@ export function CanvasWorkspace({
const rawImageUrl = String(nodeData?.src || "").trim();
if (!rawImageUrl) return null;
let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl;
if (browserOrigin && rawImageUrl.startsWith("/")) {
imageUrl = `${browserOrigin}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("//")) {
if (browserOrigin && rawImageUrl.startsWith("//")) {
imageUrl = `${window.location.protocol}${rawImageUrl}`;
} else if (browserOrigin && rawImageUrl.startsWith("/")) {
imageUrl = `${browserOrigin}${rawImageUrl}`;
}
const name =
String(nodeData?.title || "").trim() ||
@ -1769,32 +1934,18 @@ export function CanvasWorkspace({
}
setIsDownloadSubmitting(true);
const toastId = toast.loading("正在准备所选图片...");
try {
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/canvas/download-zip";
form.style.display = "none";
const payloadInput = document.createElement("input");
payloadInput.type = "hidden";
payloadInput.name = "payload";
payloadInput.value = JSON.stringify({
folderName,
files,
const result = await downloadCanvasSelectionZip({ folderName, files,
selectionKey: `${useCanvasStore.getState().projectId}:${selectedImageNodes.map(node => node.id).sort().join(",")}`,
onProgress: task => toast.loading(`${task.stage}(${task.progress}%)`, { id: toastId }),
});
form.appendChild(payloadInput);
document.body.appendChild(form);
form.submit();
form.remove();
toast.success("已开始打包下载,请留意浏览器下载栏");
} catch (_error) {
toast.error("下载失败,请稍后重试");
if (result.failedCount) toast.warning(`已开始下载 ${result.successCount} 张图片,${result.failedCount} 张未能读取;ZIP 内附失败清单`, { id: toastId, duration: 10000 });
else toast.success(`ZIP 已准备完成,正在下载 ${result.successCount} 张图片`, { id: toastId });
} catch (error) {
toast.error(error instanceof Error ? error.message : "打包下载失败,请稍后重试", { id: toastId });
} finally {
window.setTimeout(() => {
setIsDownloadSubmitting(false);
}, 800);
setIsDownloadSubmitting(false);
}
}, [isDownloadSubmitting, selectedImageNodes]);
@ -1824,7 +1975,14 @@ export function CanvasWorkspace({
// 渲染节点
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 isImageNode = node.type === "image";
const enableImageResizeHandles =
@ -2021,13 +2179,17 @@ export function CanvasWorkspace({
pushHistory();
}}
bounds="parent"
enableResizing={enableImageResizeHandles}
enableResizing={canvasImageSelectionSession ? false : enableImageResizeHandles}
resizeHandleStyles={imageResizeHandleStyles}
disableDragging={tool !== 'select'}
disableDragging={tool !== 'select' || Boolean(canvasImageSelectionSession)}
cancel={CANVAS_DRAG_CANCEL_SELECTOR}
className={cn(
"absolute",
tool === "select" && "cursor-pointer"
canvasImageSelectionSession
? isCanvasImageSelectionCandidate
? "cursor-crosshair"
: "cursor-default"
: tool === "select" && "cursor-pointer"
)}
data-canvas-node-id={node.id}
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
@ -2038,6 +2200,12 @@ export function CanvasWorkspace({
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
if (canvasImageSelectionSession) {
if (isCanvasImageSelectionCandidate) {
toggleCanvasImageSelection(node.id);
}
return;
}
setAgentSlotContextMenu(null);
clearLatestGeneratedImageHighlights();
setSelectedEdgeId(null);
@ -2172,6 +2340,29 @@ export function CanvasWorkspace({
</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>
);
};
@ -2196,7 +2387,8 @@ export function CanvasWorkspace({
isWorkflowCanvas ? "bg-[#050607] text-white" : "bg-background",
tool === 'pan' && "cursor-grab",
tool === 'draw' && "cursor-crosshair",
isPanning && "cursor-grabbing"
isPanning && "cursor-grabbing",
canvasImageSelectionSession && "cursor-crosshair"
)}
style={
isWorkflowCanvas
@ -2209,12 +2401,47 @@ export function CanvasWorkspace({
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={handleBlankCanvasDoubleClick}
onContextMenu={handleBlankCanvasContextMenu}
onWheel={handleWheel}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
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 ? (
<>
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
@ -2494,7 +2721,9 @@ export function CanvasWorkspace({
)}
{/* 选中图片快捷操作 */}
{selectedPendingImageUploadCount > 0 && selectedImageNodes.length === 0 && (
{!canvasImageSelectionSession &&
selectedPendingImageUploadCount > 0 &&
selectedImageNodes.length === 0 && (
<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"
data-canvas-overlay
@ -2502,12 +2731,16 @@ export function CanvasWorkspace({
图片仍在本地,上传完成后可进行 AI 处理
</div>
)}
{selectedImageNodes.length > 0 && (
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
<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-selection-actions="true"
>
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
@ -2529,27 +2762,77 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label}
</Button>
) : null}
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
className={cn(
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
isWorkflowCanvas &&
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting}
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Download}
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
/>
{isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
</Button>
{isWorkflowCanvas &&
selectedImageNodes.length >= 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
className={cn(
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
isWorkflowCanvas &&
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={() =>
requestImageNodeAction(selectedImageNodes[0].id, "layout-replace", {
args: {
sourceNodeIds: selectedImageNodes
.slice(0, 5)
.map((selectedNode) => selectedNode.id),
},
})
}
title={selectedImageLayoutReplaceItem.label}
>
<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>
)}

View 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

View 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}
</>
);
}

View 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>
</>
);
}
);

View 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>;
}

View 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>
);
}

Some files were not shown because too many files have changed in this diff Show More