feat: surface image queue status across generators
This commit is contained in:
parent
0213047d2d
commit
5d8146ca11
@ -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(
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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"
|
||||
? "当前这屏还没有回传结果,继续批量时会从这张开始补跑。"
|
||||
: "生成完成后,这里支持直接在当前页面改字和单张重生。"}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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" && (
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 || "图片生成消耗积分",
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@ -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
254
lib/style-pro/task.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user