feat: surface image queue status across generators
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-18 23:48:49 +08:00
parent 0213047d2d
commit 5d8146ca11
18 changed files with 1568 additions and 236 deletions

View File

@ -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(

View File

@ -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<string, unknown>,
});
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,
});
}

View File

@ -1840,9 +1840,19 @@ async function processSceneWithDiversity(
sceneIndex: number;
requestOffset: number;
requestId: string;
ownerKey?: string;
candidates: SceneFusionModelCandidate[];
imageHashCache: Map<string, string>;
modelCooldown: SceneFusionModelCooldown;
onQueueStatus?: (queueStatus: {
queuePosition: number;
sameModelPendingCount: number;
sameModelRunningCount: number;
concurrencyLimit: number;
estimatedWaitMs: number;
source: "model_queue";
}) => void | Promise<void>;
onProcessingStart?: () => void | Promise<void>;
}
): 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<ReturnType<typeof processSceneWithProvider>>;
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";

View File

@ -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<string, number>();
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<Record<string, string[]>>(
(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<string, string[]>
@ -153,7 +154,114 @@ function validateSelections(
};
}
function buildStyleProTaskRequest(params: {
body: z.infer<typeof generateRequestSchema>;
generationMode: StyleProGenerationMode;
normalizedSelections: StyleProSelectionMap;
selectedOptions: SelectedOption[];
selectedModels: Array<{
model: {
id: string;
displayName: string;
};
}>;
config: ReturnType<typeof normalizeStyleProConfig>;
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<string, unknown>,
});
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);
}
}
}

View File

@ -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({

View File

@ -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") {

View File

@ -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}参考图”生成`
}
/>
</div>
)}
@ -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"
? "当前这屏还没有回传结果,继续批量时会从这张开始补跑。"
: "生成完成后,这里支持直接在当前页面改字和单张重生。"}

View File

@ -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<ChatMessage[]>((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}
/>
)}

View File

@ -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<string, string> = {
@ -36,6 +40,22 @@ const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, 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 (
<div
@ -107,6 +146,8 @@ export const ImageGenerationLoading = memo(
<div className="text-[13px] font-medium">
使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel}
</div>
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
{(phase !== "processing" || elapsedSeconds > 0) && (
<div className="text-[13px]">
{PHASE_HINT[phase]} {elapsedLabel}

View File

@ -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 (
<div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden p-0">
<img
alt="原图"
@ -2882,9 +3048,14 @@ export function SceneFusionView({
{item.status === "processing" ? (
<div className="relative z-10 flex flex-col items-center gap-2">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<span className="rounded bg-black/50 px-2 py-1 text-white/95 text-xs">
处理中...
</span>
<div className="max-w-[90%] rounded bg-black/55 px-2.5 py-1.5 text-center text-white/95 text-xs">
<div>{queueTitle || "处理中..."}</div>
{queueDetail ? (
<div className="mt-1 text-[11px] text-white/70">
{queueDetail}
</div>
) : null}
</div>
</div>
) : item.status === "failed" ? (
<div className="relative z-10 px-3 text-center">
@ -2893,11 +3064,20 @@ export function SceneFusionView({
</p>
</div>
) : (
<span className="relative z-10 rounded bg-black/50 px-2 py-1 font-medium text-white text-xs">
等待处理
</span>
<div className="relative z-10 max-w-[90%] rounded bg-black/55 px-2.5 py-1.5 text-center text-white text-xs">
<div className="font-medium">
{queueTitle || "等待处理"}
</div>
{queueDetail ? (
<div className="mt-1 text-[11px] text-white/70">
{queueDetail}
</div>
) : null}
</div>
)}
</div>
);
})()
)}
{item.status === "completed" && (

View File

@ -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 (
<div className="min-h-[460px] rounded-lg border border-white/10 bg-white p-5 text-neutral-800">
<div className="min-h-[620px] rounded-lg border border-white/10 bg-white p-5 text-neutral-800 lg:min-h-[720px]">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-[15px] text-neutral-700">
正在创建图片
{title}
</div>
<div className="mt-1 text-xs text-neutral-400">
正在理解图片与提示词组合
{description}
</div>
</div>
<span className="inline-flex items-center gap-2 rounded-lg bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
生成中
{!isFailed ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{isFailed ? "失败" : isQueued ? "排队中" : "生成中"}
</span>
</div>
<div className="style-pro-generating-frame relative mx-auto h-[390px] max-h-[58vh] w-full max-w-[340px] overflow-hidden rounded-[28px] p-[2px]">
<div className="style-pro-generating-frame relative mx-auto h-[520px] max-h-[62vh] w-full max-w-[430px] overflow-hidden rounded-[28px] p-[2px]">
<div className="style-pro-generating-surface relative h-full overflow-hidden rounded-[26px] bg-[#f4f4f4]">
<div className="style-pro-dot-field absolute inset-0" />
<div className="style-pro-light-field absolute inset-0" />
@ -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<HTMLDivElement | null>(null);
const updatePositionFromClientX = useCallback((clientX: number) => {
@ -618,13 +711,13 @@ function BeforeAfterSlider({
[]
);
if (generating) {
return <GeneratingImagePlaceholder />;
if (showTaskState || taskState?.status === "failed") {
return <GeneratingImagePlaceholder taskState={taskState} />;
}
if (!beforeUrl && !afterUrl) {
return (
<div className="flex min-h-[460px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400">
<div className="flex min-h-[620px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400 lg:min-h-[720px]">
上传图片后开始生成
</div>
);
@ -632,7 +725,7 @@ function BeforeAfterSlider({
if (!afterUrl) {
return (
<div className="min-h-[460px] rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="min-h-[620px] rounded-lg border border-gray-200 bg-gray-50 p-3 lg:min-h-[720px]">
<ImagePreview alt="上传图片" imageUrl={beforeUrl} />
</div>
);
@ -640,7 +733,7 @@ function BeforeAfterSlider({
return (
<div
className="relative min-h-[460px] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50"
className="relative min-h-[620px] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50 lg:min-h-[720px]"
ref={sliderRef}
>
<ImagePreview
@ -697,6 +790,7 @@ export function StyleProWorkspace({
persistedState,
}: StyleProWorkspaceProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activeGenerateRequestRef = useRef<string | null>(null);
const { data: brandingData } = useSWR<BrandingSettings>(
"/api/site-settings?scope=branding",
fetcher
@ -750,7 +844,11 @@ export function StyleProWorkspace({
useState<StyleProGenerationMode>("style");
const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
const [generationTaskState, setGenerationTaskState] =
useState<StyleProTaskState | null>(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<NonNullable<StyleProGenerateResponse["task"]>["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({
))
)}
<section className="section-card p-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<section className="section-card p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_300px] 2xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="rounded-[20px] bg-black/[0.025] p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
@ -1294,7 +1475,9 @@ export function StyleProWorkspace({
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
{generating
? "正在创建图片"
? generationTaskState?.status === "pending"
? "排队中"
: "生成中"
: currentUnitCost
? `执行 · ${currentUnitCost}积分`
: "执行"}
@ -1314,7 +1497,7 @@ export function StyleProWorkspace({
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
generating={generating}
taskState={generationTaskState}
/>
</div>
@ -1323,7 +1506,7 @@ export function StyleProWorkspace({
<span className="subsection-title">历史记录</span>
<span className="text-[12px] text-black/40">最近 4 条</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-1">
{history.length > 0 ? (
history.map((item) => (
<button
@ -1337,7 +1520,7 @@ export function StyleProWorkspace({
onClick={() => restoreHistoryItem(item)}
type="button"
>
<div className="aspect-square bg-gray-100">
<div className="aspect-[4/3] bg-gray-100">
<img
alt="风格化历史"
className="h-full w-full object-cover"
@ -1356,7 +1539,7 @@ export function StyleProWorkspace({
</button>
))
) : (
<div className="col-span-2 rounded-2xl border border-dashed border-black/15 bg-white p-5 text-center text-[13px] text-black/40">
<div className="col-span-2 rounded-2xl border border-dashed border-black/15 bg-white p-5 text-center text-[13px] text-black/40 xl:col-span-1">
生成后会在这里保存历史。
</div>
)}

View File

@ -14,7 +14,10 @@ import { createKrea } from "@/lib/ai/providers/krea";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import {
acquireCanvasImageModelSlot,
getCanvasImageModelQueueStatus,
type CanvasImageModelQueueStatus,
normalizeCanvasImageModelKey,
recordCanvasImageModelExecutionDuration,
} from "./image-concurrency";
import {
recordAiModelHealthFailure,
@ -37,6 +40,12 @@ export interface CanvasGenerateImageRequest {
debugLabel?: string;
debugMeta?: Record<string, unknown>;
internalFailoverTriedModelRefs?: string[];
onQueueStatusChange?: (
status: CanvasImageModelQueueStatus & {
modelName?: string | null;
modelRef?: string | null;
}
) => void | Promise<void>;
}
export interface ParsedImage {
@ -521,8 +530,31 @@ export async function generateCanvasImage(
providerType: provider.type,
...debugMeta,
},
onQueued: request.onQueueStatusChange
? async (queueStatus) => {
await request.onQueueStatusChange?.({
...queueStatus,
modelName: selectedModel.model.displayName || null,
modelRef: selectedModel.model.id || null,
});
}
: undefined,
});
if (slot.waitMs > 0 && request.onQueueStatusChange) {
const runningStatus = await getCanvasImageModelQueueStatus({
modelKey: resolvedModelKey,
});
await request.onQueueStatusChange({
...runningStatus,
status: "running",
queuePosition: 0,
estimatedWaitMs: 0,
modelName: selectedModel.model.displayName || null,
modelRef: selectedModel.model.id || null,
});
}
let caughtError: unknown = null;
try {
@ -588,6 +620,11 @@ export async function generateCanvasImage(
});
});
recordCanvasImageModelExecutionDuration(
resolvedModelKey,
Date.now() - startedAt
);
return {
images: finalImages,
model: selectedModel.model.displayName,

View File

@ -10,6 +10,8 @@ import {
} from "./image-task-config";
const CONCURRENCY_CONFIG_CACHE_MS = 5 * 1000;
const RECENT_EXECUTION_SAMPLE_LIMIT = 24;
const DEFAULT_ESTIMATED_WAIT_MS = 45 * 1000;
const concurrencyStateKey = "__canvas_image_model_concurrency__";
interface ModelWaiter {
@ -23,6 +25,7 @@ interface ModelConcurrencyState {
waiters: ModelWaiter[];
dispatchTimer: ReturnType<typeof setTimeout> | null;
delayedUntil: number | null;
recentExecutionDurationsMs: number[];
}
interface CanvasImageConcurrencyState {
@ -62,6 +65,7 @@ function getOrCreateModelState(modelKey: string) {
waiters: [],
dispatchTimer: null,
delayedUntil: null,
recentExecutionDurationsMs: [],
};
state.models.set(modelKey, created);
return created;
@ -83,7 +87,11 @@ function cleanupModelState(modelKey: string) {
return;
}
if (modelState.running.size === 0 && modelState.waiters.length === 0) {
if (
modelState.running.size === 0 &&
modelState.waiters.length === 0 &&
modelState.recentExecutionDurationsMs.length === 0
) {
clearModelDispatchTimer(modelState);
state.models.delete(modelKey);
}
@ -179,6 +187,96 @@ export function getCanvasImageRunningCountForModel(modelKey: string) {
return getGlobalConcurrencyState().models.get(normalizedModelKey)?.running.size || 0;
}
export type CanvasImageModelQueueStatus = {
status: "pending" | "running" | "succeeded" | "failed";
queuePosition: number;
sameModelPendingCount: number;
sameModelRunningCount: number;
concurrencyLimit: number;
estimatedWaitMs: number;
source: "model_queue";
};
function getRecentAverageExecutionMs(modelState: ModelConcurrencyState) {
const samples = modelState.recentExecutionDurationsMs.filter(
(value) => Number.isFinite(value) && value > 0
);
if (samples.length === 0) {
return DEFAULT_ESTIMATED_WAIT_MS;
}
const total = samples.reduce((sum, value) => sum + value, 0);
return Math.max(1000, Math.round(total / samples.length));
}
function buildModelQueueSnapshot(params: {
modelState: ModelConcurrencyState;
queuePosition: number;
sameModelPendingCount: number;
sameModelRunningCount: number;
concurrencyLimit: number;
}): CanvasImageModelQueueStatus {
const averageExecutionMs = getRecentAverageExecutionMs(params.modelState);
const estimatedWaitMs =
params.sameModelPendingCount <= 0
? 0
: Math.max(
averageExecutionMs,
Math.ceil((params.queuePosition + 1) / Math.max(1, params.concurrencyLimit)) *
averageExecutionMs
);
return {
status: "pending",
queuePosition: Math.max(0, params.queuePosition),
sameModelPendingCount: Math.max(0, params.sameModelPendingCount),
sameModelRunningCount: Math.max(0, params.sameModelRunningCount),
concurrencyLimit: Math.max(1, params.concurrencyLimit),
estimatedWaitMs,
source: "model_queue",
};
}
export async function getCanvasImageModelQueueStatus(params: {
modelKey: string;
}): Promise<CanvasImageModelQueueStatus> {
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey);
const { concurrency: maxConcurrent } = await getCanvasImageTaskQueueConfig();
const modelState =
getGlobalConcurrencyState().models.get(normalizedModelKey) || {
running: new Map<string, string>(),
waiters: [],
dispatchTimer: null,
delayedUntil: null,
recentExecutionDurationsMs: [],
};
return buildModelQueueSnapshot({
modelState,
queuePosition: modelState.waiters.length > 0 ? modelState.waiters.length - 1 : 0,
sameModelPendingCount: modelState.waiters.length,
sameModelRunningCount: modelState.running.size,
concurrencyLimit: maxConcurrent,
});
}
export function recordCanvasImageModelExecutionDuration(
modelKey: string,
durationMs: number
) {
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return;
}
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
const modelState = getOrCreateModelState(normalizedModelKey);
modelState.recentExecutionDurationsMs.push(durationMs);
if (modelState.recentExecutionDurationsMs.length > RECENT_EXECUTION_SAMPLE_LIMIT) {
modelState.recentExecutionDurationsMs.splice(
0,
modelState.recentExecutionDurationsMs.length - RECENT_EXECUTION_SAMPLE_LIMIT
);
}
}
async function drainModelWaiters(
modelKey: string,
trigger: "arrival" | "slot-release" | "timer"
@ -241,6 +339,7 @@ export async function acquireCanvasImageModelSlot(params: {
ownerKey?: string;
debugLabel?: string;
debugMeta?: Record<string, unknown>;
onQueued?: (status: CanvasImageModelQueueStatus) => void | Promise<void>;
}) {
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey);
const normalizedOwnerKey = normalizeCanvasImageOwnerKey(params.ownerKey);
@ -276,11 +375,29 @@ export async function acquireCanvasImageModelSlot(params: {
}
await new Promise<void>((resolve) => {
const queuePosition = modelState.waiters.length;
modelState.waiters.push({
ticketId,
ownerKey: normalizedOwnerKey,
resolve,
});
if (typeof params.onQueued === "function") {
const queueStatus = buildModelQueueSnapshot({
modelState,
queuePosition,
sameModelPendingCount: modelState.waiters.length,
sameModelRunningCount: modelState.running.size,
concurrencyLimit: maxConcurrent,
});
Promise.resolve(params.onQueued(queueStatus)).catch((error) => {
console.warn("[Canvas Image Concurrency] onQueued callback failed:", {
debugLabel: params.debugLabel || "canvas-image",
modelKey: normalizedModelKey,
error: error instanceof Error ? error.message : String(error),
...params.debugMeta,
});
});
}
void drainModelWaiters(normalizedModelKey, "arrival");
});

View File

@ -1,10 +1,11 @@
import "server-only";
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, lt, notInArray, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasImageTask } from "@/lib/db/schema";
import {
getCanvasImageBatchDispatchIntervalMs,
getCanvasImageRunningCountForModel,
getCanvasImageSingleModelConcurrency,
normalizeCanvasImageModelKey,
} from "./image-concurrency";
@ -13,8 +14,26 @@ import {
type CanvasGenerateImageRequest,
type CanvasGenerateImageResult,
} from "./generate-image";
import {
executeStyleProTask,
getStyleProTaskPrimaryModelKey,
type StyleProTaskRequest,
type StyleProTaskResult,
} from "@/lib/style-pro/task";
type CanvasImageTaskStatus = "pending" | "running" | "succeeded" | "failed";
type CanvasImageTaskRequest = CanvasGenerateImageRequest | StyleProTaskRequest;
type CanvasImageTaskResult = CanvasGenerateImageResult | StyleProTaskResult;
export type CanvasImageTaskQueueStatus = {
status: CanvasImageTaskStatus;
queuePosition: number;
sameModelPendingCount: number;
sameModelRunningCount: number;
concurrencyLimit: number;
estimatedWaitMs: number;
source: "model_queue" | "batch_queue";
};
interface PendingTaskEntry {
taskId: string;
@ -41,6 +60,9 @@ interface QueueState {
const STALE_RUNNING_MS = 5 * 60 * 1000;
const PENDING_LOOKAHEAD_LIMIT = 100;
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
const DEFAULT_ESTIMATED_WAIT_MS = 45 * 1000;
const queueStateKey = "__canvas_image_task_queue_state__";
function getQueueState(): QueueState {
@ -64,6 +86,10 @@ function getQueueState(): QueueState {
}
function normalizeTaskModelKey(request: unknown) {
if (isStyleProTaskRequest(request)) {
return normalizeCanvasImageModelKey(getStyleProTaskPrimaryModelKey(request));
}
if (!request || typeof request !== "object" || Array.isArray(request)) {
return "__default__";
}
@ -72,6 +98,15 @@ function normalizeTaskModelKey(request: unknown) {
return normalizeCanvasImageModelKey(rawModel);
}
function isStyleProTaskRequest(request: unknown): request is StyleProTaskRequest {
return Boolean(
request &&
typeof request === "object" &&
!Array.isArray(request) &&
(request as { taskKind?: unknown }).taskKind === "style-pro"
);
}
function getModelUserKey(modelKey: string, userId: string) {
return `${modelKey}::${userId || "__anonymous__"}`;
}
@ -326,15 +361,21 @@ async function runSingleTask(entry: PendingTaskEntry) {
return;
}
const result = await generateCanvasImage({
...(task.request as CanvasGenerateImageRequest),
ownerKey: task.userId,
});
const request = task.request as CanvasImageTaskRequest;
const result = isStyleProTaskRequest(request)
? await executeStyleProTask({
request,
userId: String(task.userId || entry.userId),
})
: await generateCanvasImage({
...(request as CanvasGenerateImageRequest),
ownerKey: task.userId,
});
await db
.update(canvasImageTask)
.set({
status: "succeeded",
result: result as CanvasGenerateImageResult,
result: result as CanvasImageTaskResult,
error: null,
completedAt: new Date(),
updatedAt: new Date(),
@ -388,7 +429,7 @@ export async function enqueueCanvasImageTask(taskId: string) {
export async function createCanvasImageTask(params: {
userId: string;
request: CanvasGenerateImageRequest;
request: Record<string, unknown>;
}) {
const [task] = await db
.insert(canvasImageTask)
@ -409,6 +450,126 @@ export async function createCanvasImageTask(params: {
return task;
}
async function estimateModelQueueWaitMs(params: {
modelKey: string;
sameModelRunningCount: number;
queuePosition: number;
concurrencyLimit: number;
}) {
const rows = await db
.select({
request: canvasImageTask.request,
startedAt: canvasImageTask.startedAt,
completedAt: canvasImageTask.completedAt,
})
.from(canvasImageTask)
.where(eq(canvasImageTask.status, "succeeded"))
.orderBy(desc(canvasImageTask.completedAt))
.limit(QUEUE_STATUS_RECENT_SAMPLE_LIMIT);
const durations = rows
.filter((row) => normalizeTaskModelKey(row.request) === params.modelKey)
.map((row) => {
if (!row.startedAt || !row.completedAt) return 0;
return row.completedAt.getTime() - row.startedAt.getTime();
})
.filter((value) => Number.isFinite(value) && value > 1000)
.slice(0, 12);
const averageDurationMs =
durations.length > 0
? Math.round(durations.reduce((sum, value) => sum + value, 0) / durations.length)
: DEFAULT_ESTIMATED_WAIT_MS;
const immediatelyStartable = Math.max(
params.concurrencyLimit - params.sameModelRunningCount,
0
);
if (params.queuePosition < immediatelyStartable) {
return 0;
}
const tasksAheadAfterImmediateSlots = Math.max(
0,
params.queuePosition - immediatelyStartable
);
const waveCount =
Math.floor(
tasksAheadAfterImmediateSlots / Math.max(1, params.concurrencyLimit)
) +
(params.sameModelRunningCount > 0 || immediatelyStartable === 0 ? 1 : 0);
return Math.max(0, waveCount * averageDurationMs);
}
async function buildCanvasImageTaskQueueStatus(task: {
id: string;
status: CanvasImageTaskStatus;
request: unknown;
createdAt: Date;
}) {
const modelKey = normalizeTaskModelKey(task.request);
const concurrencyLimit = await getCanvasImageSingleModelConcurrency();
const runningRows = await db
.select({
request: canvasImageTask.request,
})
.from(canvasImageTask)
.where(eq(canvasImageTask.status, "running"));
const sameModelRunningCount = Math.max(
runningRows.filter((row) => normalizeTaskModelKey(row.request) === modelKey).length,
getCanvasImageRunningCountForModel(modelKey)
);
if (task.status !== "pending") {
return {
status: task.status,
queuePosition: 0,
sameModelPendingCount: 0,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs: 0,
source: "model_queue" as const,
};
}
const pendingRows = await db
.select({
id: canvasImageTask.id,
request: canvasImageTask.request,
createdAt: canvasImageTask.createdAt,
})
.from(canvasImageTask)
.where(eq(canvasImageTask.status, "pending"))
.orderBy(asc(canvasImageTask.createdAt))
.limit(QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT);
const sameModelPendingRows = pendingRows.filter(
(row) => normalizeTaskModelKey(row.request) === modelKey
);
const queuePosition = Math.max(
sameModelPendingRows.findIndex((row) => row.id === task.id),
0
);
const sameModelPendingCount = sameModelPendingRows.length;
const estimatedWaitMs = await estimateModelQueueWaitMs({
modelKey,
sameModelRunningCount,
queuePosition,
concurrencyLimit,
});
return {
status: task.status,
queuePosition,
sameModelPendingCount,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs,
source: "model_queue" as const,
};
}
export async function getCanvasImageTaskForUser(params: {
taskId: string;
userId: string;
@ -470,5 +631,13 @@ export async function getCanvasImageTaskForUser(params: {
await drainCanvasImageTaskQueue();
}
return task;
return {
...task,
queueStatus: await buildCanvasImageTaskQueueStatus({
id: task.id,
status: task.status as CanvasImageTaskStatus,
request: task.request,
createdAt: task.createdAt,
}),
};
}

View File

@ -3481,6 +3481,8 @@ export async function consumeUserPoints({
if (!Number.isFinite(amount) || amount <= 0) {
return { ok: true, balance: null as number | null, skipped: false };
}
const normalizedMessageId =
typeof messageId === "string" && messageId.trim() ? messageId.trim() : null;
try {
return await db.transaction(async (tx) => {
@ -3506,6 +3508,29 @@ export async function consumeUserPoints({
};
}
if (normalizedMessageId) {
const existingLedgerRows = await tx
.select({ id: pointsLedger.id })
.from(pointsLedger)
.where(
and(
eq(pointsLedger.userId, userId),
eq(pointsLedger.messageId, normalizedMessageId),
eq(pointsLedger.reason, reason),
eq(pointsLedger.amount, -amount)
)
)
.limit(1);
if (existingLedgerRows.length > 0) {
return {
ok: true,
balance: Math.max(0, Number(currentUser.balance || 0)),
skipped: true,
};
}
}
const updated = await tx
.update(user)
.set({
@ -3527,7 +3552,7 @@ export async function consumeUserPoints({
reason,
modelId: modelId || null,
chatId: chatId || null,
messageId: messageId || null,
messageId: normalizedMessageId,
note: note || "图片生成消耗积分",
});

View File

@ -10,6 +10,7 @@ type FeaturePointsChargeInput = {
unitCost: number;
reason: string;
modelId?: string | null;
messageId?: string | null;
note: string;
pointsConfig?: PointsSystemConfig;
};
@ -20,6 +21,7 @@ type FeaturePointsChargeOk = {
unitCost: number;
consume: (overrides?: {
modelId?: string | null;
messageId?: string | null;
note?: string;
reason?: string;
}) => Promise<{
@ -90,6 +92,7 @@ export async function prepareFeaturePointsCharge(
amount: unitCost,
reason: overrides.reason || input.reason,
modelId: overrides.modelId ?? input.modelId ?? null,
messageId: overrides.messageId ?? input.messageId ?? null,
note: overrides.note || input.note,
});
},

View File

@ -23,8 +23,77 @@ export type PublicStyleProModelOption = {
creditDiscountLabel: string | null;
};
const VIDEO_PROVIDER_TYPES = new Set([
"grsai-video",
"qianwen-video",
"doubao-video",
"ruxa-video",
"volcano-video",
"fal-video",
"runway",
]);
const VIDEO_MODEL_KEYWORDS = [
"/generate/video/",
"video",
"veo",
"sora",
"kling",
"hailuo",
"seedance",
"runway",
"gen-3",
"gen-4",
"gen 3",
"gen 4",
"wan-2.",
"wan 2.",
"t2v",
"i2v",
];
function hasVideoGenerationConfig(value: unknown) {
if (!value || typeof value !== "object") return false;
const config = value as Record<string, unknown>;
return (
"creditCostPerVideo" in config ||
"creditCostPerVideoByDuration" in config ||
"creditCostPerVideoByResolution" in config ||
"videoDurationOptions" in config ||
"videoResolutionOptions" in config
);
}
function hasVideoCapabilities(value: unknown) {
if (!value || typeof value !== "object") return false;
const serialized = JSON.stringify(value).toLowerCase();
return serialized.includes("text-to-video") || serialized.includes("image-to-video");
}
function isVideoLikeModel(entry: StyleProModelEntry) {
if (VIDEO_PROVIDER_TYPES.has(entry.provider.type)) return true;
if (
hasVideoGenerationConfig(entry.model.imageGenConfig) ||
hasVideoCapabilities(entry.model.capabilities) ||
hasVideoCapabilities(entry.model.defaultParams)
) {
return true;
}
const searchable = [
entry.provider.type,
entry.provider.name,
entry.model.modelId,
entry.model.displayName,
entry.model.description,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return VIDEO_MODEL_KEYWORDS.some((keyword) => searchable.includes(keyword));
}
export function isStyleProImageModel(entry: StyleProModelEntry) {
return Boolean(entry.model.supportsImageGen);
return Boolean(entry.model.supportsImageGen) && !isVideoLikeModel(entry);
}
export async function getStyleProImageModelEntries(options?: {

254
lib/style-pro/task.ts Normal file
View File

@ -0,0 +1,254 @@
import "server-only";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
generateCanvasImage,
type CanvasGenerateImageResult,
} from "@/lib/canvas/generate-image";
import type {
StyleProGenerationMode,
StyleProSelectionMap,
} from "./config";
export type StyleProTaskSelectedOption = {
categoryId: string;
categoryTitle: string;
jsonKey: string;
optionId: string;
optionLabel: string;
prompt: string;
};
export type StyleProTaskModelRef = {
id: string;
displayName: string;
};
export type StyleProTaskBilling = {
unitCost: number;
reason: string;
modelId: string;
messageId?: string | null;
note: string;
};
export type StyleProTaskRequest = {
taskKind: "style-pro";
canvasId?: string | null;
sourceImageUrl: string;
generationMode: StyleProGenerationMode;
normalizedSelections: StyleProSelectionMap;
selectedOptions: StyleProTaskSelectedOption[];
selectedModels: StyleProTaskModelRef[];
optionLabels: string[];
prompts: {
defaultPrompt: string;
realisticFirstPrompt?: string;
realisticSecondPrompt?: string;
negativePrompt?: string;
aspectRatio?: string;
imageSize?: string;
};
billing: StyleProTaskBilling;
requestedStyleProModelId?: string | null;
};
export type StyleProTaskResult = {
imageUrl: string;
thumbnailUrl?: string;
model?: string;
modelRef?: string;
generationMode: StyleProGenerationMode;
chargedPoints: number;
selections: StyleProSelectionMap;
optionLabels: string[];
};
function composeStyleProPrompt({
defaultPrompt,
selectedOptions,
mode = "style-pro-final",
}: {
defaultPrompt: string;
selectedOptions: StyleProTaskSelectedOption[];
mode?: string;
}) {
const groupedPrompt = selectedOptions.reduce<Record<string, string[]>>(
(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: selectedOptions,
promptByKey: groupedPrompt,
},
null,
2
),
]
.filter(Boolean)
.join("\n\n");
}
async function consumeStyleProTaskPoints(
userId: string,
billing: StyleProTaskBilling
) {
if (!Number.isFinite(billing.unitCost) || billing.unitCost <= 0) {
return 0;
}
try {
const pointsCharge = await prepareFeaturePointsCharge({
userId,
unitCost: billing.unitCost,
reason: billing.reason,
modelId: billing.modelId,
messageId: billing.messageId || null,
note: billing.note,
});
if (!pointsCharge.ok || !pointsCharge.chargeable) {
return 0;
}
const consumeResult = await pointsCharge.consume();
if (consumeResult?.ok && !consumeResult.skipped) {
return billing.unitCost;
}
} catch (error) {
console.error("[Style Pro Task] Failed to consume points:", error);
}
return 0;
}
function getPrimaryModelId(request: StyleProTaskRequest) {
return String(request.selectedModels[0]?.id || "").trim();
}
export function getStyleProTaskPrimaryModelKey(request: StyleProTaskRequest) {
return getPrimaryModelId(request) || "__default__";
}
function normalizeCanvasImageResultImage(
result: CanvasGenerateImageResult,
fallbackError: string
) {
const image = result.images[0];
const imageUrl = String(image?.originalUrl || image?.thumbnailUrl || "").trim();
if (!imageUrl) {
throw new Error(fallbackError);
}
return {
imageUrl,
thumbnailUrl: String(image?.thumbnailUrl || image?.originalUrl || "").trim() || imageUrl,
model: result.model,
modelRef: result.modelRef,
};
}
export async function executeStyleProTask(params: {
request: StyleProTaskRequest;
userId: string;
}) {
const { request, userId } = params;
const aspectRatio = request.prompts.aspectRatio || "auto";
const imageSize = request.prompts.imageSize || "2K";
const negativePrompt = request.prompts.negativePrompt || undefined;
const result =
request.generationMode === "realistic"
? await (async () => {
const firstModelId = getPrimaryModelId(request);
const secondModelId = String(request.selectedModels[1]?.id || "").trim();
if (!firstModelId || !secondModelId) {
throw new Error("请先在后台配置实景化两段隐藏模型");
}
const firstResult = await generateCanvasImage({
prompt: request.prompts.realisticFirstPrompt || "",
negativePrompt,
model: firstModelId,
aspectRatio,
imageSize,
imageCount: 1,
referenceImage: request.sourceImageUrl,
ownerKey: userId,
debugLabel: "style-pro-realistic-stage1",
debugMeta: {
canvasId: request.canvasId || null,
selectedOptionCount: request.selectedOptions.length,
},
});
const firstPassImage = normalizeCanvasImageResultImage(
firstResult,
"实景化第一阶段生成失败"
);
return generateCanvasImage({
prompt: composeStyleProPrompt({
defaultPrompt: request.prompts.realisticSecondPrompt || "",
selectedOptions: request.selectedOptions,
mode: "style-pro-realistic-final",
}),
negativePrompt,
model: secondModelId,
aspectRatio,
imageSize,
imageCount: 1,
referenceImage: firstPassImage.imageUrl,
ownerKey: userId,
debugLabel: "style-pro-realistic-stage2",
debugMeta: {
canvasId: request.canvasId || null,
selectedOptionCount: request.selectedOptions.length,
firstStageModelRef: firstModelId,
},
});
})()
: await generateCanvasImage({
prompt: composeStyleProPrompt({
defaultPrompt: request.prompts.defaultPrompt,
selectedOptions: request.selectedOptions,
}),
negativePrompt,
model: getPrimaryModelId(request),
aspectRatio,
imageSize,
imageCount: 1,
referenceImage: request.sourceImageUrl,
ownerKey: userId,
debugLabel: "style-pro",
debugMeta: {
canvasId: request.canvasId || null,
requestedStyleProModelId: request.requestedStyleProModelId || null,
selectedOptionCount: request.selectedOptions.length,
},
});
const finalImage = normalizeCanvasImageResultImage(result, "生成结果为空");
const chargedPoints = await consumeStyleProTaskPoints(userId, request.billing);
return {
imageUrl: finalImage.imageUrl,
thumbnailUrl: finalImage.thumbnailUrl,
model: finalImage.model,
modelRef: finalImage.modelRef,
generationMode: request.generationMode,
chargedPoints,
selections: request.normalizedSelections,
optionLabels: request.optionLabels,
} satisfies StyleProTaskResult;
}