From 5d8146ca118f99f7232ef4732cf507189d1d3dab Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 18 May 2026 23:48:49 +0800 Subject: [PATCH] feat: surface image queue status across generators --- app/(chat)/api/chat/route.ts | 111 ++++++ app/api/canvas/generate/image/route.ts | 10 +- app/api/scene-fusion/route.ts | 34 ++ app/api/style-pro/generate/route.ts | 363 ++++++++++-------- app/api/style-pro/model-options/route.ts | 4 +- app/api/user/account-center/route.ts | 6 +- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 25 +- components/canvas/CanvasChatPanel.tsx | 33 ++ components/image-generation-loading.tsx | 41 ++ components/scene-fusion-view.tsx | 214 ++++++++++- components/style-pro/StyleProWorkspace.tsx | 267 +++++++++++-- lib/canvas/generate-image.ts | 37 ++ lib/canvas/image-concurrency.ts | 119 +++++- lib/canvas/image-task-queue.ts | 185 ++++++++- lib/db/queries.ts | 27 +- lib/points/feature-charge.ts | 3 + lib/style-pro/model-options.ts | 71 +++- lib/style-pro/task.ts | 254 ++++++++++++ 18 files changed, 1568 insertions(+), 236 deletions(-) create mode 100644 lib/style-pro/task.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index cfa211380..0bf988f63 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -498,6 +498,10 @@ function writeImageGenerationStatusChunk( providerType?: string | null; modelName?: string | null; imageCount?: number | null; + queuePosition?: number | null; + sameModelRunningCount?: number | null; + concurrencyLimit?: number | null; + estimatedWaitMs?: number | null; }, label: string, ) { @@ -512,6 +516,24 @@ function writeImageGenerationStatusChunk( Number.isFinite(Number(params.imageCount)) && Number(params.imageCount) > 0 ? Number(params.imageCount) : undefined, + queuePosition: + Number.isFinite(Number(params.queuePosition)) && Number(params.queuePosition) >= 0 + ? Number(params.queuePosition) + : undefined, + sameModelRunningCount: + Number.isFinite(Number(params.sameModelRunningCount)) && + Number(params.sameModelRunningCount) >= 0 + ? Number(params.sameModelRunningCount) + : undefined, + concurrencyLimit: + Number.isFinite(Number(params.concurrencyLimit)) && + Number(params.concurrencyLimit) > 0 + ? Number(params.concurrencyLimit) + : undefined, + estimatedWaitMs: + Number.isFinite(Number(params.estimatedWaitMs)) && Number(params.estimatedWaitMs) >= 0 + ? Number(params.estimatedWaitMs) + : undefined, }, label, ); @@ -7439,6 +7461,28 @@ export async function POST(request: Request) { referenceHeight: referenceGeometry?.height, referenceAspectRatio: referenceGeometry?.aspectRatio, }, + onQueueStatusChange: + contextPayload?.source === "canvas" + ? async (queueStatus) => { + writeImageGenerationStatusChunk( + writer, + { + phase: + queueStatus.status === "running" + ? "processing" + : "queued", + modelName: + queueStatus.modelName || resolvedModelDisplayName || resolvedModelRef, + imageCount: 1, + queuePosition: queueStatus.queuePosition, + sameModelRunningCount: queueStatus.sameModelRunningCount, + concurrencyLimit: queueStatus.concurrencyLimit, + estimatedWaitMs: queueStatus.estimatedWaitMs, + }, + `agent-mode:detail-replacement:queue:${reference.id}`, + ); + } + : undefined, }); resolvedModelDisplayName = result.model || resolvedModelDisplayName; const firstImage = result.images[0]; @@ -8211,6 +8255,30 @@ export async function POST(request: Request) { referenceHeight: referenceGeometry?.height, referenceAspectRatio: referenceGeometry?.aspectRatio, }, + onQueueStatusChange: + contextPayload?.source === "canvas" + ? async (queueStatus) => { + writeImageGenerationStatusChunk( + writer, + { + phase: + queueStatus.status === "running" + ? "processing" + : "queued", + modelName: + queueStatus.modelName || + resolvedModelDisplayName || + resolvedModelRef, + imageCount: 1, + queuePosition: queueStatus.queuePosition, + sameModelRunningCount: queueStatus.sameModelRunningCount, + concurrencyLimit: queueStatus.concurrencyLimit, + estimatedWaitMs: queueStatus.estimatedWaitMs, + }, + `agent-preset:replacement:queue:${reference.id}`, + ); + } + : undefined, }); resolvedModelDisplayName = result.model || resolvedModelDisplayName; const firstImage = result.images[0]; @@ -8501,6 +8569,28 @@ export async function POST(request: Request) { agentExecutionSnapshotId: presetSnapshot.id, projectId: contextPayload?.projectId || null, }, + onQueueStatusChange: + contextPayload?.source === "canvas" + ? async (queueStatus) => { + writeImageGenerationStatusChunk( + writer, + { + phase: + queueStatus.status === "running" + ? "processing" + : "queued", + modelName: + queueStatus.modelName || selectedAgentPreset.name, + imageCount: resolvedImageCount, + queuePosition: queueStatus.queuePosition, + sameModelRunningCount: queueStatus.sameModelRunningCount, + concurrencyLimit: queueStatus.concurrencyLimit, + estimatedWaitMs: queueStatus.estimatedWaitMs, + }, + "agent-preset:image:scene-generation:queue", + ); + } + : undefined, }); console.info("[Chat API] Agent scene generation model completed:", { chatId: id, @@ -8707,6 +8797,27 @@ export async function POST(request: Request) { : effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize, imageCount: 1, referenceImages: sourceImageUrls, + onQueueStatusChange: + contextPayload?.source === "canvas" + ? async (queueStatus) => { + writeImageGenerationStatusChunk( + writer, + { + phase: + queueStatus.status === "running" + ? "processing" + : "queued", + modelName: queueStatus.modelName || slotLabel, + imageCount: 1, + queuePosition: queueStatus.queuePosition, + sameModelRunningCount: queueStatus.sameModelRunningCount, + concurrencyLimit: queueStatus.concurrencyLimit, + estimatedWaitMs: queueStatus.estimatedWaitMs, + }, + `agent-preset:image:slot-queue:${slot.slotIndex}`, + ); + } + : undefined, }); const imageMarkdown = buildAgentToolImagesMarkdown( diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts index a9632a47d..a5d8aa784 100644 --- a/app/api/canvas/generate/image/route.ts +++ b/app/api/canvas/generate/image/route.ts @@ -89,6 +89,7 @@ export async function GET(request: Request) { createdAt: task.createdAt, startedAt: task.startedAt, completedAt: task.completedAt, + queueStatus: task.queueStatus, }, }); } catch (error) { @@ -153,9 +154,13 @@ export async function POST(request: Request) { if (asyncMode) { const task = await createCanvasImageTask({ userId: session.user.id, - request: payload, + request: payload as unknown as Record, }); await enqueueCanvasImageTask(task.id); + const currentTask = await getCanvasImageTaskForUser({ + taskId: task.id, + userId: session.user.id, + }); if (pointsCharge?.chargeable) { try { await pointsCharge.consume({ @@ -171,7 +176,8 @@ export async function POST(request: Request) { return NextResponse.json({ success: true, taskId: task.id, - status: task.status, + status: currentTask?.status || task.status, + queueStatus: currentTask?.queueStatus, }); } diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index a8a1ad4bc..fb9cc21de 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -1840,9 +1840,19 @@ async function processSceneWithDiversity( sceneIndex: number; requestOffset: number; requestId: string; + ownerKey?: string; candidates: SceneFusionModelCandidate[]; imageHashCache: Map; modelCooldown: SceneFusionModelCooldown; + onQueueStatus?: (queueStatus: { + queuePosition: number; + sameModelPendingCount: number; + sameModelRunningCount: number; + concurrencyLimit: number; + estimatedWaitMs: number; + source: "model_queue"; + }) => void | Promise; + onProcessingStart?: () => void | Promise; } ): Promise<{ result: { thumbnail: string; original: string; prompt: string }; @@ -1906,6 +1916,7 @@ async function processSceneWithDiversity( const modelSlot = await acquireCanvasImageModelSlot({ modelKey: candidate.model.modelId || candidate.model.id, + ownerKey: params.ownerKey, debugLabel: "scene-fusion", debugMeta: { requestId: params.requestId, @@ -1914,9 +1925,11 @@ async function processSceneWithDiversity( modelRef: candidate.model.id, providerType: candidate.provider.type, }, + onQueued: params.onQueueStatus, }); let result: Awaited>; try { + await params.onProcessingStart?.(); result = await processSceneWithProvider( params.productImageUrl, params.sceneImageUrl, @@ -2283,9 +2296,30 @@ export async function POST(request: Request) { sceneIndex: currentIndex, requestOffset, requestId, + ownerKey: session.user.id, candidates: candidateModels, imageHashCache, modelCooldown, + onQueueStatus: (queueStatus) => { + enqueue({ + type: "queue", + data: { + id: scene.id, + status: "pending" as const, + queueStatus, + }, + }); + }, + onProcessingStart: () => { + enqueue({ + type: "queue", + data: { + id: scene.id, + status: "processing" as const, + queueStatus: null, + }, + }); + }, }); const dbImageSize = imageSize || "2K"; diff --git a/app/api/style-pro/generate/route.ts b/app/api/style-pro/generate/route.ts index c1164a622..308aec347 100644 --- a/app/api/style-pro/generate/route.ts +++ b/app/api/style-pro/generate/route.ts @@ -2,32 +2,70 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing"; -import { generateCanvasImage } from "@/lib/canvas/generate-image"; +import { + createCanvasImageTask, + enqueueCanvasImageTask, + getCanvasImageTaskForUser, +} from "@/lib/canvas/image-task-queue"; import { getSystemSetting } from "@/lib/db/queries"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; -import { - findStyleProImageModelByRef, - getStyleProImageModelEntries, -} from "@/lib/style-pro/model-options"; import { STYLE_PRO_CONFIG_KEY, type StyleProCategory, type StyleProGenerationMode, type StyleProOption, + type StyleProSelectionMap, normalizeStyleProConfig, } from "@/lib/style-pro/config"; +import { + findStyleProImageModelByRef, + getStyleProImageModelEntries, + type StyleProModelEntry, +} from "@/lib/style-pro/model-options"; +import type { + StyleProTaskRequest, + StyleProTaskResult, +} from "@/lib/style-pro/task"; export const maxDuration = 120; export const dynamic = "force-dynamic"; const generateRequestSchema = z.object({ canvasId: z.string().trim().min(1).max(160).optional(), + requestId: z.string().uuid().optional(), sourceImageUrl: z.string().trim().min(1).max(4000), generationMode: z.enum(["style", "realistic"]).optional(), modelId: z.string().trim().min(1).max(160).optional(), selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)), }); +const STYLE_PRO_REQUEST_LOCK_TTL_MS = 10 * 60 * 1000; +const styleProActiveRequestKeys = new Map(); + +function pruneExpiredStyleProRequestLocks() { + const now = Date.now(); + for (const [key, expiresAt] of styleProActiveRequestKeys.entries()) { + if (expiresAt <= now) { + styleProActiveRequestKeys.delete(key); + } + } +} + +function acquireStyleProRequestLock({ + requestId, + userId, +}: { + requestId?: string | null; + userId: string; +}) { + if (!requestId) return ""; + pruneExpiredStyleProRequestLocks(); + const key = `${userId}:${requestId}`; + if (styleProActiveRequestKeys.has(key)) return null; + styleProActiveRequestKeys.set(key, Date.now() + STYLE_PRO_REQUEST_LOCK_TTL_MS); + return key; +} + type SelectedOption = { category: StyleProCategory; option: StyleProOption; @@ -49,43 +87,6 @@ function buildSelectionPayload(selectedOptions: SelectedOption[]) { })); } -function composePrompt({ - defaultPrompt, - selectedOptions, - mode = "style-pro-final", -}: { - defaultPrompt: string; - selectedOptions: SelectedOption[]; - mode?: string; -}) { - const selectionPayload = buildSelectionPayload(selectedOptions); - const groupedPrompt = selectionPayload.reduce>( - (acc, item) => { - const key = item.jsonKey || item.categoryId; - acc[key] = acc[key] || []; - acc[key].push(item.prompt); - return acc; - }, - {} - ); - - return [ - defaultPrompt, - "请严格依据以下风格化 PRO 选择执行最终图片生成。用户不可见配置 JSON:", - JSON.stringify( - { - mode, - selections: selectionPayload, - promptByKey: groupedPrompt, - }, - null, - 2 - ), - ] - .filter(Boolean) - .join("\n\n"); -} - function validateSelections( categories: StyleProCategory[], rawSelections: Record @@ -153,7 +154,114 @@ function validateSelections( }; } +function buildStyleProTaskRequest(params: { + body: z.infer; + generationMode: StyleProGenerationMode; + normalizedSelections: StyleProSelectionMap; + selectedOptions: SelectedOption[]; + selectedModels: Array<{ + model: { + id: string; + displayName: string; + }; + }>; + config: ReturnType; + billing: { + unitCost: number; + reason: string; + modelId: string; + messageId?: string | null; + note: string; + }; +}): StyleProTaskRequest { + return { + taskKind: "style-pro", + canvasId: params.body.canvasId || null, + sourceImageUrl: params.body.sourceImageUrl, + generationMode: params.generationMode, + normalizedSelections: params.normalizedSelections, + selectedOptions: buildSelectionPayload(params.selectedOptions), + selectedModels: params.selectedModels.map((entry) => ({ + id: entry.model.id, + displayName: entry.model.displayName, + })), + optionLabels: params.selectedOptions.map( + ({ category, option }) => `${category.title}:${option.label}` + ), + prompts: { + defaultPrompt: params.config.defaultPrompt, + realisticFirstPrompt: params.config.realisticFirstPrompt || undefined, + realisticSecondPrompt: params.config.realisticSecondPrompt || undefined, + negativePrompt: params.config.negativePrompt || undefined, + aspectRatio: params.config.aspectRatio || "auto", + imageSize: params.config.imageSize || "2K", + }, + billing: params.billing, + requestedStyleProModelId: + params.generationMode === "style" + ? params.body.modelId || params.selectedModels[0]?.model.id || null + : 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 taskId = String(searchParams.get("taskId") || "").trim(); + if (!taskId) { + return NextResponse.json( + { success: false, error: "缺少 taskId 参数" }, + { status: 400 } + ); + } + + const task = await getCanvasImageTaskForUser({ + taskId, + userId: session.user.id, + }); + if (!task || (task.request as { taskKind?: unknown })?.taskKind !== "style-pro") { + return NextResponse.json( + { success: false, error: "任务不存在" }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + task: { + id: task.id, + status: task.status, + attempts: task.attempts, + error: task.error || null, + result: (task.result || null) as StyleProTaskResult | null, + createdAt: task.createdAt, + startedAt: task.startedAt, + completedAt: task.completedAt, + queueStatus: task.queueStatus, + }, + }); + } catch (error) { + console.error("[Style Pro Generate][TaskStatus] Failed:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "查询任务失败", + }, + { status: 500 } + ); + } +} + export async function POST(request: Request) { + let requestLockKey = ""; try { const session = await auth(); if (!session?.user?.id) { @@ -164,6 +272,18 @@ export async function POST(request: Request) { } const body = generateRequestSchema.parse(await request.json()); + const acquiredRequestLockKey = acquireStyleProRequestLock({ + requestId: body.requestId, + userId: session.user.id, + }); + if (acquiredRequestLockKey === null) { + return NextResponse.json( + { success: false, error: "当前图片正在生成中,请稍候" }, + { status: 409 } + ); + } + requestLockKey = acquiredRequestLockKey; + const generationMode: StyleProGenerationMode = body.generationMode === "realistic" ? "realistic" : "style"; if (isDisallowedImageUrl(body.sourceImageUrl)) { @@ -206,7 +326,7 @@ export async function POST(request: Request) { includeHidden: generationMode === "realistic", }); - const selectedModels = + const selectedModels: StyleProModelEntry[] = generationMode === "realistic" ? (() => { const firstModel = findStyleProImageModelByRef( @@ -226,17 +346,16 @@ export async function POST(request: Request) { imageModelEntries, config.modelId ); - let selectedModel = + let selectedModel: StyleProModelEntry | null = configuredDefaultModel || imageModelEntries.find((entry) => entry.model.isDefault) || imageModelEntries[0] || null; if (body.modelId) { - selectedModel = findStyleProImageModelByRef( - imageModelEntries, - body.modelId - ); + selectedModel = + findStyleProImageModelByRef(imageModelEntries, body.modelId) || + null; if (!selectedModel) { return []; } @@ -250,7 +369,11 @@ export async function POST(request: Request) { { status: 400 } ); } - if (generationMode === "style" && body.modelId && selectedModels.length === 0) { + if ( + generationMode === "style" && + body.modelId && + selectedModels.length === 0 + ) { return NextResponse.json( { success: false, error: "所选生图模型不可用" }, { status: 400 } @@ -262,30 +385,32 @@ export async function POST(request: Request) { { status: 400 } ); } + const unitCost = selectedModels.reduce( - (sum, entry) => sum + getModelGenerationCreditCost(entry.model, "image"), + (sum, entry) => + sum + getModelGenerationCreditCost(entry.model as any, "image"), 0 ); const billingModelId = generationMode === "realistic" ? selectedModels.map((entry) => entry.model.id).join("+") : selectedModels[0].model.id; - const billingModelName = - generationMode === "realistic" - ? selectedModels.map((entry) => entry.model.displayName).join(" + ") - : selectedModels[0].model.displayName; - const pointsCharge = await prepareFeaturePointsCharge({ - userId: session.user.id, + const billing = { unitCost, reason: generationMode === "realistic" ? "style_pro_realistic_generation" : "style_pro_generation", modelId: billingModelId, + messageId: body.requestId || null, note: generationMode === "realistic" - ? `风格化 PRO 实景化生图消耗积分(${billingModelName})` - : `风格化 PRO 生图消耗积分(${billingModelName})`, + ? "风格化 PRO 实景化生图消耗积分" + : "风格化 PRO 生图消耗积分", + }; + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + ...billing, }); if (!pointsCharge.ok) { return NextResponse.json(pointsCharge.payload, { @@ -293,110 +418,30 @@ export async function POST(request: Request) { }); } - const prompt = composePrompt({ - defaultPrompt: config.defaultPrompt, + const taskRequest = buildStyleProTaskRequest({ + body, + generationMode, + normalizedSelections: selectionResult.normalizedSelections, selectedOptions: selectionResult.selectedOptions, + selectedModels, + config, + billing, + }); + const task = await createCanvasImageTask({ + userId: session.user.id, + request: taskRequest as unknown as Record, + }); + await enqueueCanvasImageTask(task.id); + const currentTask = await getCanvasImageTaskForUser({ + taskId: task.id, + userId: session.user.id, }); - const result = - generationMode === "realistic" - ? await (async () => { - const firstModel = selectedModels[0]; - const secondModel = selectedModels[1]; - const firstResult = await generateCanvasImage({ - prompt: config.realisticFirstPrompt, - negativePrompt: config.negativePrompt || undefined, - model: firstModel.model.id, - aspectRatio: config.aspectRatio || "auto", - imageSize: config.imageSize || "2K", - imageCount: 1, - referenceImage: body.sourceImageUrl, - ownerKey: session.user.id, - debugLabel: "style-pro-realistic-stage1", - debugMeta: { - canvasId: body.canvasId || null, - selectedOptionCount: selectionResult.selectedOptions.length, - }, - }); - const intermediateImage = firstResult.images[0]?.originalUrl; - if (!intermediateImage) { - throw new Error("实景化第一阶段生成失败"); - } - return generateCanvasImage({ - prompt: composePrompt({ - defaultPrompt: config.realisticSecondPrompt, - selectedOptions: selectionResult.selectedOptions, - mode: "style-pro-realistic-final", - }), - negativePrompt: config.negativePrompt || undefined, - model: secondModel.model.id, - aspectRatio: config.aspectRatio || "auto", - imageSize: config.imageSize || "2K", - imageCount: 1, - referenceImage: intermediateImage, - ownerKey: session.user.id, - debugLabel: "style-pro-realistic-stage2", - debugMeta: { - canvasId: body.canvasId || null, - selectedOptionCount: selectionResult.selectedOptions.length, - firstStageModelRef: firstModel.model.id, - }, - }); - })() - : await generateCanvasImage({ - prompt, - negativePrompt: config.negativePrompt || undefined, - model: selectedModels[0].model.id, - aspectRatio: config.aspectRatio || "auto", - imageSize: config.imageSize || "2K", - imageCount: 1, - referenceImage: body.sourceImageUrl, - ownerKey: session.user.id, - debugLabel: "style-pro", - debugMeta: { - canvasId: body.canvasId || null, - requestedStyleProModelId: body.modelId || null, - selectedOptionCount: selectionResult.selectedOptions.length, - }, - }); - const image = result.images[0]; - if (!image?.originalUrl) { - return NextResponse.json( - { success: false, error: "生成结果为空" }, - { status: 502 } - ); - } - let chargedPoints = 0; - if (pointsCharge.chargeable) { - try { - const consumeResult = await pointsCharge.consume(); - if (consumeResult?.ok && !consumeResult.skipped) { - chargedPoints = pointsCharge.unitCost; - } else if (!consumeResult?.ok) { - console.warn("[Style Pro Generate] Failed to consume points:", { - userId: session.user.id, - modelId: billingModelId, - unitCost: pointsCharge.unitCost, - }); - } - } catch (pointsError) { - console.error("[Style Pro Generate] Points consume failed:", pointsError); - } - } return NextResponse.json({ success: true, - data: { - imageUrl: image.originalUrl, - thumbnailUrl: image.thumbnailUrl || image.originalUrl, - model: result.model, - modelRef: result.modelRef, - generationMode, - chargedPoints, - selections: selectionResult.normalizedSelections, - optionLabels: selectionResult.selectedOptions.map( - ({ category, option }) => `${category.title}:${option.label}` - ), - }, + taskId: task.id, + status: currentTask?.status || task.status, + queueStatus: currentTask?.queueStatus, }); } catch (error) { console.error("[Style Pro Generate] Failed:", error); @@ -413,5 +458,9 @@ export async function POST(request: Request) { }, { status: 500 } ); + } finally { + if (requestLockKey) { + styleProActiveRequestKeys.delete(requestLockKey); + } } } diff --git a/app/api/style-pro/model-options/route.ts b/app/api/style-pro/model-options/route.ts index e3c1f5293..fa80e2c6b 100644 --- a/app/api/style-pro/model-options/route.ts +++ b/app/api/style-pro/model-options/route.ts @@ -49,8 +49,8 @@ export async function GET() { ); const realisticUnitCost = realisticFirstModel && realisticSecondModel - ? getModelGenerationCreditCost(realisticFirstModel.model, "image") + - getModelGenerationCreditCost(realisticSecondModel.model, "image") + ? getModelGenerationCreditCost(realisticFirstModel.model as any, "image") + + getModelGenerationCreditCost(realisticSecondModel.model as any, "image") : 0; return NextResponse.json({ diff --git a/app/api/user/account-center/route.ts b/app/api/user/account-center/route.ts index 9b063fe21..9de450fd7 100644 --- a/app/api/user/account-center/route.ts +++ b/app/api/user/account-center/route.ts @@ -72,12 +72,14 @@ function formatLedgerDetail(params: { } const trimmedNote = typeof note === "string" ? note.trim() : ""; + if (reason === "style_pro_generation") { + return "风格化 PRO 生图消耗积分"; + } if (reason === "style_pro_realistic_generation") { return "风格化 PRO 实景化生图消耗积分"; } if (reason === "realtime_canvas_generation") { - const sanitizedRealtimeNote = trimmedNote.replace(/(.*$/, "").trim(); - return sanitizedRealtimeNote || "实时绘画生成消耗积分"; + return "实时绘画生成消耗积分"; } if (reason === "model3d_task") { diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 07eb42989..e2d915d4d 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -2849,6 +2849,15 @@ export function AgentGenerationWorkspaceDarkV2({ index, resultUrl, resultText, + queuePosition: + activeRunIndex >= 0 && pendingGeneration?.modeType === "detail_replacement" + ? activeRunIndex + 1 + : null, + queueTotal: + pendingGeneration?.modeType === "detail_replacement" && + Array.isArray(pendingGeneration.requestedCardIds) + ? pendingGeneration.requestedCardIds.length + : null, status: getReplacementCardStatus({ index, resultUrl, @@ -6937,7 +6946,13 @@ export function AgentGenerationWorkspaceDarkV2({ animated={card.status === "generating"} showSpinner={card.status === "generating"} title={`结果图 ${card.index + 1}`} - description={`将按“输入图组 + 第 ${card.index + 1} 张${detailReferenceSourceLabel}参考图”生成`} + description={ + card.status === "queued" && + card.queuePosition && + card.queueTotal + ? `批次内排队 ${card.queuePosition}/${card.queueTotal},将按“输入图组 + 第 ${card.index + 1} 张${detailReferenceSourceLabel}参考图”继续生成` + : `将按“输入图组 + 第 ${card.index + 1} 张${detailReferenceSourceLabel}参考图”生成` + } /> )} @@ -6946,7 +6961,9 @@ export function AgentGenerationWorkspaceDarkV2({ ? card.status === "generating" ? "正在重做这一屏,新的结果回来后会直接覆盖当前展示。" : card.status === "queued" - ? "这一屏已加入当前批量队列,前面的屏完成后会自动继续。" + ? card.queuePosition && card.queueTotal + ? `这一屏已加入当前批量队列,当前批次位次 ${card.queuePosition}/${card.queueTotal}。` + : "这一屏已加入当前批量队列,前面的屏完成后会自动继续。" : card.status === "passthrough" ? "当前参考图未检测到可替换产品,本屏先沿用参考图展示;如需继续调整,仍可在当前页改字或重新生图。" : card.status === "fallback" @@ -6955,7 +6972,9 @@ export function AgentGenerationWorkspaceDarkV2({ : card.status === "generating" ? "当前正在生成这一屏,请稍候。" : card.status === "queued" - ? "前面的屏生成完成后,会继续处理这一屏。" + ? card.queuePosition && card.queueTotal + ? `当前批次位次 ${card.queuePosition}/${card.queueTotal},前面的屏完成后会继续处理这一屏。` + : "前面的屏生成完成后,会继续处理这一屏。" : card.status === "missing" ? "当前这屏还没有回传结果,继续批量时会从这张开始补跑。" : "生成完成后,这里支持直接在当前页面改字和单张重生。"} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index fe73beb9c..1d778e55d 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -142,6 +142,10 @@ type ImageGenerationStatusStreamPayload = { providerType?: string; modelName?: string; imageCount?: number; + queuePosition?: number; + sameModelRunningCount?: number; + concurrencyLimit?: number; + estimatedWaitMs?: number; }; type AgentTargetLockRefPreview = { @@ -4808,6 +4812,26 @@ export function CanvasChatPanel({ providerType: payload.providerType, modelName: getAgentSafeModelName(payload.modelName), imageCount: Number(payload.imageCount || 0) || undefined, + queuePosition: + Number.isFinite(Number(payload.queuePosition)) && + Number(payload.queuePosition) >= 0 + ? Number(payload.queuePosition) + : undefined, + sameModelRunningCount: + Number.isFinite(Number(payload.sameModelRunningCount)) && + Number(payload.sameModelRunningCount) >= 0 + ? Number(payload.sameModelRunningCount) + : undefined, + concurrencyLimit: + Number.isFinite(Number(payload.concurrencyLimit)) && + Number(payload.concurrencyLimit) > 0 + ? Number(payload.concurrencyLimit) + : undefined, + estimatedWaitMs: + Number.isFinite(Number(payload.estimatedWaitMs)) && + Number(payload.estimatedWaitMs) >= 0 + ? Number(payload.estimatedWaitMs) + : undefined, }); } catch (e) { console.error("[CanvasChat] Failed to parse data-imageGenerationStatus:", e); @@ -9166,6 +9190,11 @@ export function CanvasChatPanel({ streamImageGenerationState?.phase === "failed" ? streamImageGenerationState.phase : "processing"; + const loadingQueuePosition = streamImageGenerationState?.queuePosition; + const loadingSameModelRunningCount = + streamImageGenerationState?.sameModelRunningCount; + const loadingConcurrencyLimit = streamImageGenerationState?.concurrencyLimit; + const loadingEstimatedWaitMs = streamImageGenerationState?.estimatedWaitMs; const renderableVisibleMessages = useMemo(() => { return visibleMessages.reduce((acc, message) => { const rawParts = (message.parts || []) as any[]; @@ -10328,6 +10357,10 @@ export function CanvasChatPanel({ providerType={loadingProviderType} modelName={loadingModelName} phase={loadingPhase} + queuePosition={loadingQueuePosition} + sameModelRunningCount={loadingSameModelRunningCount} + concurrencyLimit={loadingConcurrencyLimit} + estimatedWaitMs={loadingEstimatedWaitMs} /> )} diff --git a/components/image-generation-loading.tsx b/components/image-generation-loading.tsx index c873c6e3d..2e0baf0af 100644 --- a/components/image-generation-loading.tsx +++ b/components/image-generation-loading.tsx @@ -14,6 +14,10 @@ interface ImageGenerationLoadingProps { providerType?: string; phase?: "idle" | "queued" | "processing" | "finalizing" | "failed"; elapsedSeconds?: number; + queuePosition?: number; + sameModelRunningCount?: number; + concurrencyLimit?: number; + estimatedWaitMs?: number; } const PROVIDER_MODEL_LABEL: Record = { @@ -36,6 +40,22 @@ const PHASE_HINT: Record, stri failed: "生成失败,请重试。", }; +function formatImageQueueWaitLabel(estimatedWaitMs?: number) { + const numeric = Number(estimatedWaitMs || 0); + if (!Number.isFinite(numeric) || numeric <= 0) { + return "预计即将开始"; + } + const seconds = Math.max(1, Math.round(numeric / 1000)); + if (seconds < 60) { + return `预计 ${seconds} 秒`; + } + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return remainSeconds > 0 + ? `预计 ${minutes} 分 ${remainSeconds} 秒` + : `预计 ${minutes} 分钟`; +} + export const ImageGenerationLoading = memo( ({ imageCount = 1, @@ -44,6 +64,10 @@ export const ImageGenerationLoading = memo( providerType, phase = "processing", elapsedSeconds = 0, + queuePosition, + sameModelRunningCount, + concurrencyLimit, + estimatedWaitMs, }: ImageGenerationLoadingProps) => { const count = Math.min(Math.max(imageCount, 1), maxImages); const resolvedModelName = @@ -52,6 +76,21 @@ export const ImageGenerationLoading = memo( const elapsedLabel = elapsedSeconds > 0 ? `已等待 ${elapsedSeconds}s` : `预计 ${estimateRangeLabel}`; const isFailed = phase === "failed"; + const queueLine = + phase === "queued" && Number.isFinite(queuePosition) + ? Math.max(0, Number(queuePosition || 0)) > 0 + ? `排队中,前方 ${Math.max(0, Number(queuePosition || 0))} 个` + : "排队中,等待进入执行" + : null; + const queueDetailLine = + phase === "queued" + ? `当前模型运行 ${Math.max( + 0, + Number(sameModelRunningCount || 0) + )}/${Math.max(1, Number(concurrencyLimit || 0) || 1)},${formatImageQueueWaitLabel( + estimatedWaitMs + )}` + : null; return (
使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel}
+ {queueLine ?
{queueLine}
: null} + {queueDetailLine ?
{queueDetailLine}
: null} {(phase !== "processing" || elapsedSeconds > 0) && (
{PHASE_HINT[phase]} {elapsedLabel} diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index f813a8c7a..1cbc071e1 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -61,6 +61,36 @@ const PERSISTED_IMAGE_MAX_LENGTH = 200_000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +function getLocalBatchQueueMeta(items: ProcessItem[], itemId: string) { + const activeQueue = items.filter( + (item) => item.status === "processing" || item.status === "pending" + ); + const position = activeQueue.findIndex((item) => item.id === itemId); + if (position < 0) { + return null; + } + return { + position: position + 1, + total: activeQueue.length, + }; +} + +function formatQueueWaitLabel(estimatedWaitMs?: number | null) { + const waitMs = Number(estimatedWaitMs); + if (!Number.isFinite(waitMs) || waitMs <= 0) { + return "预计即将开始"; + } + const totalSeconds = Math.max(1, Math.round(waitMs / 1000)); + if (totalSeconds < 60) { + return `预计 ${totalSeconds} 秒`; + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds > 0 + ? `预计 ${minutes} 分 ${seconds} 秒` + : `预计 ${minutes} 分钟`; +} + type BatchMode = | "scene-fission" | "multi-product-scene" @@ -79,6 +109,14 @@ interface ProcessItem { error?: string; targetName?: string; customPrompt?: string; + queueStatus?: { + queuePosition?: number; + sameModelPendingCount?: number; + sameModelRunningCount?: number; + concurrencyLimit?: number; + estimatedWaitMs?: number; + source?: "model_queue" | "batch_queue"; + }; } interface SceneFusionViewProps { @@ -421,6 +459,16 @@ export function SceneFusionView({ if (mode === "random-bg") return randomItems; return batchItems; }, [batchItems, mode, multiProductItems, randomItems, sceneItems]); + const resultQueueMetaById = useMemo( + () => + new Map( + resultItems.map((item) => [ + item.id, + getLocalBatchQueueMeta(resultItems, item.id), + ]) + ), + [resultItems] + ); const modeCanStart = useMemo(() => { if (mode === "scene-fission") @@ -1117,10 +1165,11 @@ export function SceneFusionView({ type: string; data?: { id: string; - status: "completed" | "failed"; + status: "pending" | "processing" | "completed" | "failed"; result?: string; originalUrl?: string; error?: string; + queueStatus?: ProcessItem["queueStatus"] | null; productName?: string; productSource?: "user" | "agent" | "none"; sceneTargets?: Array<{ @@ -1163,6 +1212,23 @@ export function SceneFusionView({ return; } + if (msg.type === "queue" && msg.data) { + const data = msg.data; + setter((prev) => + prev.map((item) => + item.id === data.id + ? { + ...item, + status: data.status, + queueStatus: data.queueStatus || undefined, + error: data.status === "failed" ? item.error : undefined, + } + : item + ) + ); + return; + } + if (msg.type === "result" && msg.data) { const data = msg.data; if (data.status === "completed") { @@ -1175,6 +1241,7 @@ export function SceneFusionView({ status: "completed", result: data.result, originalUrl: data.originalUrl, + queueStatus: undefined, error: undefined, } : item @@ -1190,6 +1257,7 @@ export function SceneFusionView({ ? { ...item, status: "failed", + queueStatus: undefined, error: data.error || "生成失败", } : item @@ -1218,10 +1286,11 @@ export function SceneFusionView({ type: string; data?: { id: string; - status: "completed" | "failed"; + status: "pending" | "processing" | "completed" | "failed"; result?: string; originalUrl?: string; error?: string; + queueStatus?: ProcessItem["queueStatus"] | null; productName?: string; productSource?: "user" | "agent" | "none"; sceneTargets?: Array<{ @@ -1250,10 +1319,11 @@ export function SceneFusionView({ type: string; data?: { id: string; - status: "completed" | "failed"; + status: "pending" | "processing" | "completed" | "failed"; result?: string; originalUrl?: string; error?: string; + queueStatus?: ProcessItem["queueStatus"] | null; productName?: string; productSource?: "user" | "agent" | "none"; sceneTargets?: Array<{ @@ -1279,10 +1349,11 @@ export function SceneFusionView({ setter((prev) => prev.map((item) => scenesPayload.some((scene) => scene.id === item.id) && - item.status === "processing" + (item.status === "processing" || item.status === "pending") ? { ...item, status: "failed", + queueStatus: undefined, error: streamErrorMessage || "处理失败", } : item @@ -1302,8 +1373,8 @@ export function SceneFusionView({ setter((prev) => prev.map((item) => scenesPayload.some((scene) => scene.id === item.id) && - item.status === "processing" - ? { ...item, status: "failed", error: message } + (item.status === "processing" || item.status === "pending") + ? { ...item, status: "failed", queueStatus: undefined, error: message } : item ) ); @@ -1316,6 +1387,7 @@ export function SceneFusionView({ productImageInput: string; sceneImageInput: string; requestId: string; + itemId?: string; targetName?: string; customPrompt?: string; }) => { @@ -1365,14 +1437,35 @@ export function SceneFusionView({ type: string; data?: { id: string; - status: "completed" | "failed"; + status: "pending" | "processing" | "completed" | "failed"; result?: string; originalUrl?: string; error?: string; + queueStatus?: ProcessItem["queueStatus"] | null; }; error?: string; }; + if ( + msg.type === "queue" && + msg.data?.id === params.requestId && + params.itemId + ) { + setMultiProductItems((prev) => + prev.map((item) => + item.id === params.itemId + ? { + ...item, + status: msg.data?.status || item.status, + queueStatus: msg.data?.queueStatus || undefined, + error: undefined, + } + : item + ) + ); + continue; + } + if (msg.type === "result" && msg.data?.id === params.requestId) { if (msg.data.status === "completed") { return { @@ -1399,6 +1492,7 @@ export function SceneFusionView({ productImageInput: string; sceneImageInput: string; requestId: string; + itemId?: string; targetName?: string; customPrompt?: string; }) => { @@ -1462,7 +1556,7 @@ export function SceneFusionView({ setMultiProductItems((prev) => prev.map((item) => itemsToProcess.some((target) => target.id === item.id) - ? { ...item, status: "processing", error: undefined } + ? { ...item, status: "pending", queueStatus: undefined, error: undefined } : item ) ); @@ -1480,12 +1574,20 @@ export function SceneFusionView({ const current = queue.shift(); if (!current) break; const requestId = `multi-product-${current.id}-${Date.now()}`; + setMultiProductItems((prev) => + prev.map((item) => + item.id === current.id + ? { ...item, status: "processing", error: undefined } + : item + ) + ); try { const result = await runSingleSceneFusionRequest({ productImageInput: current.image, sceneImageInput: referenceSceneImage, requestId, + itemId: current.id, targetName: referenceTargetName || undefined, customPrompt: referenceCustomPrompt || undefined, }); @@ -1498,6 +1600,7 @@ export function SceneFusionView({ status: "completed", result: result.result, originalUrl: result.originalUrl, + queueStatus: undefined, error: undefined, } : item @@ -1512,6 +1615,7 @@ export function SceneFusionView({ ? { ...item, status: "failed", + queueStatus: undefined, error: message, } : item @@ -1636,9 +1740,12 @@ export function SceneFusionView({ } setIsProcessing(true); - updateBatchItemsStatus( - itemsToProcess.map((item) => item.id), - "processing" + setBatchItems((prev) => + prev.map((item) => + itemsToProcess.some((target) => target.id === item.id) + ? { ...item, status: "pending", error: undefined } + : item + ) ); const queue = [...itemsToProcess]; @@ -1653,6 +1760,13 @@ export function SceneFusionView({ while (queue.length > 0) { const current = queue.shift(); if (!current) break; + setBatchItems((prev) => + prev.map((item) => + item.id === current.id + ? { ...item, status: "processing", error: undefined } + : item + ) + ); try { const result = await processBatchImageItem(current); successCount += 1; @@ -2873,6 +2987,58 @@ export function SceneFusionView({ src={item.result} /> ) : ( + (() => { + const queueMeta = resultQueueMetaById.get(item.id) || null; + const serverRunningCount = Math.max( + 0, + Number(item.queueStatus?.sameModelRunningCount || 0) + ); + const concurrencyLimit = Math.max( + 0, + Number(item.queueStatus?.concurrencyLimit || 0) + ); + const serverQueuePosition = Math.max( + 0, + Number(item.queueStatus?.queuePosition || 0) + ); + const queueTitle = + item.status === "pending" + ? serverQueuePosition > 0 + ? `排队中,前方 ${serverQueuePosition} 个` + : queueMeta + ? `批次内排队 ${queueMeta.position}/${queueMeta.total}` + : "等待处理" + : item.status === "processing" + ? serverQueuePosition > 0 + ? `模型排队中,前方 ${serverQueuePosition} 个` + : queueMeta + ? `批次内执行 ${queueMeta.position}/${queueMeta.total}` + : "处理中..." + : null; + const queueDetail = + item.status === "pending" + ? serverQueuePosition > 0 + ? `当前模型运行 ${serverRunningCount}/${Math.max( + concurrencyLimit, + serverRunningCount, + 1 + )},${formatQueueWaitLabel( + item.queueStatus?.estimatedWaitMs + )}` + : queueMeta + ? `当前批次位次 ${queueMeta.position}/${queueMeta.total}` + : null + : item.status === "processing" && + serverQueuePosition > 0 + ? `当前模型运行 ${serverRunningCount}/${Math.max( + concurrencyLimit, + serverRunningCount, + 1 + )},${formatQueueWaitLabel( + item.queueStatus?.estimatedWaitMs + )}` + : null; + return (
原图 - - 处理中... - +
+
{queueTitle || "处理中..."}
+ {queueDetail ? ( +
+ {queueDetail} +
+ ) : null} +
) : item.status === "failed" ? (
@@ -2893,11 +3064,20 @@ export function SceneFusionView({

) : ( - - 等待处理 - +
+
+ {queueTitle || "等待处理"} +
+ {queueDetail ? ( +
+ {queueDetail} +
+ ) : null} +
)}
+ ); + })() )} {item.status === "completed" && ( diff --git a/components/style-pro/StyleProWorkspace.tsx b/components/style-pro/StyleProWorkspace.tsx index c471551cf..017be0228 100644 --- a/components/style-pro/StyleProWorkspace.tsx +++ b/components/style-pro/StyleProWorkspace.tsx @@ -91,15 +91,36 @@ type StyleProAccountCenterResponse = { type StyleProGenerateResponse = { success: boolean; error?: string; - data?: { - imageUrl: string; - thumbnailUrl?: string; - model?: string; - modelRef?: string; - generationMode?: StyleProGenerationMode; - chargedPoints?: number; - selections: StyleProSelectionMap; - optionLabels: string[]; + taskId?: string; + status?: string; + queueStatus?: { + status?: "pending" | "running" | "succeeded" | "failed"; + queuePosition?: number; + sameModelPendingCount?: number; + sameModelRunningCount?: number; + concurrencyLimit?: number; + estimatedWaitMs?: number; + source?: "model_queue" | "batch_queue"; + }; + task?: { + id: string; + status: "pending" | "running" | "succeeded" | "failed"; + attempts: number; + error?: string | null; + createdAt?: string | Date | null; + startedAt?: string | Date | null; + completedAt?: string | Date | null; + queueStatus?: StyleProGenerateResponse["queueStatus"]; + result?: { + imageUrl: string; + thumbnailUrl?: string; + model?: string; + modelRef?: string; + generationMode?: StyleProGenerationMode; + chargedPoints?: number; + selections: StyleProSelectionMap; + optionLabels: string[]; + } | null; }; }; @@ -113,6 +134,16 @@ type StyleProWorkspaceProps = { }; const STYLE_PRO_HISTORY_LIMIT = 4; +const STYLE_PRO_TASK_POLL_INTERVAL_MS = 1600; +const STYLE_PRO_TASK_TIMEOUT_MS = 4 * 60 * 1000; + +type StyleProTaskState = { + taskId: string; + status: "pending" | "running" | "succeeded" | "failed"; + queueStatus?: StyleProGenerateResponse["queueStatus"]; + error?: string | null; + generationMode: StyleProGenerationMode; +}; function getHistoryId() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { @@ -121,6 +152,13 @@ function getHistoryId() { return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`; } +function createStyleProRequestId() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return undefined; +} + function loadImageDimensions(url: string) { return new Promise<{ width?: number; height?: number }>((resolve) => { if (typeof window === "undefined" || !url) { @@ -139,6 +177,10 @@ function loadImageDimensions(url: string) { }); } +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + function normalizeHistory(history: StyleProHistoryItem[] | undefined) { return Array.isArray(history) ? history.slice(0, STYLE_PRO_HISTORY_LIMIT) @@ -257,6 +299,22 @@ function formatStyleProHistoryDateTime(value?: string | null) { return `${dateText} ${timeText}`; } +function formatStyleProEstimatedWait(estimatedWaitMs?: number | null) { + const numeric = Number(estimatedWaitMs || 0); + if (!Number.isFinite(numeric) || numeric <= 0) { + return "预计即将开始"; + } + const seconds = Math.max(1, Math.round(numeric / 1000)); + if (seconds < 60) { + return `预计 ${seconds} 秒`; + } + const minutes = Math.floor(seconds / 60); + const remainSeconds = seconds % 60; + return remainSeconds > 0 + ? `预计 ${minutes} 分 ${remainSeconds} 秒` + : `预计 ${minutes} 分钟`; +} + function StyleProAccountActions() { const { data: session, status } = useSession(); const [accountCenterOpen, setAccountCenterOpen] = useState(false); @@ -466,24 +524,57 @@ function ImagePreview({ ); } -function GeneratingImagePlaceholder() { +function GeneratingImagePlaceholder({ + taskState, +}: { + taskState?: StyleProTaskState | null; +}) { + const queueStatus = taskState?.queueStatus; + const queuePosition = Math.max(0, Number(queueStatus?.queuePosition || 0)); + const sameModelRunningCount = Math.max( + 0, + Number(queueStatus?.sameModelRunningCount || 0) + ); + const concurrencyLimit = Math.max( + 0, + Number(queueStatus?.concurrencyLimit || 0) + ); + const isQueued = taskState?.status === "pending"; + const isFailed = taskState?.status === "failed"; + const title = isFailed + ? "本次生成失败" + : isQueued + ? `排队中,前方 ${queuePosition} 个` + : taskState?.generationMode === "realistic" + ? "正在生成实景化结果" + : "正在生成图片"; + const description = isFailed + ? taskState?.error || "请调整参数后重试。" + : isQueued + ? `当前模型运行 ${sameModelRunningCount}/${concurrencyLimit || "?"},${formatStyleProEstimatedWait( + queueStatus?.estimatedWaitMs + )}` + : taskState?.generationMode === "realistic" + ? "系统会先完成基础实景化,再合成最终风格结果。" + : "模型已开始执行,结果完成后会自动回填。"; + return ( -
+
- 正在创建图片 + {title}
- 正在理解图片与提示词组合 + {description}
- - 生成中 + {!isFailed ? : null} + {isFailed ? "失败" : isQueued ? "排队中" : "生成中"}
-
+
@@ -562,12 +653,14 @@ function GeneratingImagePlaceholder() { function BeforeAfterSlider({ afterUrl, beforeUrl, - generating = false, + taskState, }: { afterUrl?: string | null; beforeUrl?: string | null; - generating?: boolean; + taskState?: StyleProTaskState | null; }) { + const showTaskState = + taskState?.status === "pending" || taskState?.status === "running"; const [position, setPosition] = useState(50); const sliderRef = useRef(null); const updatePositionFromClientX = useCallback((clientX: number) => { @@ -618,13 +711,13 @@ function BeforeAfterSlider({ [] ); - if (generating) { - return ; + if (showTaskState || taskState?.status === "failed") { + return ; } if (!beforeUrl && !afterUrl) { return ( -
+
上传图片后开始生成
); @@ -632,7 +725,7 @@ function BeforeAfterSlider({ if (!afterUrl) { return ( -
+
); @@ -640,7 +733,7 @@ function BeforeAfterSlider({ return (
(null); + const activeGenerateRequestRef = useRef(null); const { data: brandingData } = useSWR( "/api/site-settings?scope=branding", fetcher @@ -750,7 +844,11 @@ export function StyleProWorkspace({ useState("style"); const [selectedModelId, setSelectedModelId] = useState(""); const [uploading, setUploading] = useState(false); - const [generating, setGenerating] = useState(false); + const [generationTaskState, setGenerationTaskState] = + useState(null); + const generating = + generationTaskState?.status === "pending" || + generationTaskState?.status === "running"; useEffect(() => { if (modelOptions.length === 0) { @@ -867,6 +965,7 @@ export function StyleProWorkspace({ const nextUrl = String(url || "").trim(); setSourceImageUrl(nextUrl); setCurrentResult(null); + setGenerationTaskState(null); await persistState({ sourceImageUrl: nextUrl, history }); }, [history, persistState] @@ -920,6 +1019,10 @@ export function StyleProWorkspace({ ); const handleGenerate = useCallback(async () => { + if (activeGenerateRequestRef.current || generating) { + toast.info("当前图片正在生成中,请稍候"); + return; + } if (!sourceImageUrl) { toast.error("请先上传一张图片"); return; @@ -933,13 +1036,23 @@ export function StyleProWorkspace({ return; } - setGenerating(true); + const requestId = createStyleProRequestId(); + const localRequestKey = requestId || `local-${Date.now()}`; + activeGenerateRequestRef.current = localRequestKey; + setGenerationTaskState({ + taskId: localRequestKey, + status: "pending", + error: null, + generationMode, + }); + try { const response = await fetch("/api/style-pro/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ canvasId, + ...(requestId ? { requestId } : {}), generationMode, modelId: generationMode === "style" @@ -952,20 +1065,76 @@ export function StyleProWorkspace({ const payload = (await response.json().catch(() => ({}))) as | StyleProGenerateResponse | undefined; - if (!response.ok || !payload?.success || !payload.data?.imageUrl) { + if (!response.ok || !payload?.success) { throw new Error(payload?.error || "生成失败"); } - const dimensions = await loadImageDimensions(payload.data.imageUrl); + const taskId = String(payload.taskId || "").trim(); + if (!taskId) { + throw new Error("生成任务创建失败"); + } + setGenerationTaskState({ + taskId, + status: payload.status === "running" ? "running" : "pending", + queueStatus: payload.queueStatus, + error: null, + generationMode, + }); + + const deadline = Date.now() + STYLE_PRO_TASK_TIMEOUT_MS; + let taskResult: + | NonNullable["result"]> + | null = null; + let lastError = ""; + + while (Date.now() < deadline) { + await sleep(STYLE_PRO_TASK_POLL_INTERVAL_MS); + + const statusResponse = await fetch( + `/api/style-pro/generate?taskId=${encodeURIComponent(taskId)}`, + { cache: "no-store" } + ); + const statusPayload = (await statusResponse.json().catch(() => ({}))) as + | StyleProGenerateResponse + | undefined; + if (!statusResponse.ok || !statusPayload?.success || !statusPayload.task) { + lastError = + statusPayload?.error || `查询任务失败(${statusResponse.status})`; + continue; + } + + const task = statusPayload.task; + setGenerationTaskState({ + taskId: task.id, + status: task.status, + queueStatus: task.queueStatus, + error: task.error || null, + generationMode, + }); + + if (task.status === "succeeded") { + taskResult = task.result || null; + break; + } + if (task.status === "failed") { + throw new Error(task.error || "生成失败"); + } + } + + if (!taskResult?.imageUrl) { + throw new Error(lastError || "生成超时,请稍后重试"); + } + + const dimensions = await loadImageDimensions(taskResult.imageUrl); const item: StyleProHistoryItem = { id: getHistoryId(), sourceImageUrl, - resultImageUrl: payload.data.imageUrl, - selections: payload.data.selections || selections, - optionLabels: payload.data.optionLabels || selectedOptionLabels, + resultImageUrl: taskResult.imageUrl, + selections: taskResult.selections || selections, + optionLabels: taskResult.optionLabels || selectedOptionLabels, createdAt: new Date().toISOString(), - generationMode: payload.data.generationMode || generationMode, - model: payload.data.model, - modelRef: payload.data.modelRef, + generationMode: taskResult.generationMode || generationMode, + model: taskResult.model, + modelRef: taskResult.modelRef, width: dimensions.width, height: dimensions.height, }; @@ -976,17 +1145,28 @@ export function StyleProWorkspace({ setHistory(nextHistory); setCurrentResult(item); await persistState({ sourceImageUrl, history: nextHistory }); + setGenerationTaskState(null); toast.success("风格化 PRO 生成完成"); } catch (error) { + setGenerationTaskState((current) => + current + ? { + ...current, + status: "failed", + error: error instanceof Error ? error.message : "生成失败", + } + : null + ); toast.error(error instanceof Error ? error.message : "生成失败"); } finally { - setGenerating(false); + activeGenerateRequestRef.current = null; } }, [ canvasId, config?.enabled, - history, generationMode, + generating, + history, missingRequiredCategories, persistState, selectedOptionLabels, @@ -1001,6 +1181,7 @@ export function StyleProWorkspace({ setSelections(item.selections || {}); setGenerationMode(item.generationMode || "style"); setCurrentResult(item); + setGenerationTaskState(null); }, []); const disabledReason = !sourceImageUrl @@ -1206,8 +1387,8 @@ export function StyleProWorkspace({ )) )} -
-
+
+
@@ -1294,7 +1475,9 @@ export function StyleProWorkspace({ )} {generating - ? "正在创建图片" + ? generationTaskState?.status === "pending" + ? "排队中" + : "生成中" : currentUnitCost ? `执行 · ${currentUnitCost}积分` : "执行"} @@ -1314,7 +1497,7 @@ export function StyleProWorkspace({
@@ -1323,7 +1506,7 @@ export function StyleProWorkspace({ 历史记录 最近 4 条
-
+
{history.length > 0 ? ( history.map((item) => (