diff --git a/app/api/enhance/pro/route.ts b/app/api/enhance/pro/route.ts index 979b652d3..1893d630b 100644 --- a/app/api/enhance/pro/route.ts +++ b/app/api/enhance/pro/route.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { and, eq, or } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; @@ -11,7 +12,11 @@ import { getUserById, upsertMediaAsset, } from "@/lib/db/queries"; -import { generatedImage } from "@/lib/db/schema"; +import { + enhanceProTask, + generatedImage, + pointsLedger, +} from "@/lib/db/schema"; import { ENHANCE_PRO_KREA_SETTING_KEY, normalizeEnhanceProKreaConfig, @@ -24,7 +29,6 @@ import { calculateEnhanceProPointsEstimate } from "@/lib/points/config"; import { uploadFile } from "@/lib/storage/service"; const ENHANCE_PRO_CREATE_TIMEOUT_MS = 120_000; -const ENHANCE_PRO_SYNC_WAIT_TIMEOUT_MS = 20_000; const ENHANCE_PRO_TASK_POLL_INTERVAL_MS = 1_500; const ENHANCE_PRO_MODEL_ID = "krea-topaz-generative"; const ENHANCE_PRO_MAX_RESULT_IMAGES = 4; @@ -37,6 +41,7 @@ type EnhanceProRequestBody = { prompt?: string; chatId?: string | null; messageId?: string | null; + clientRequestId?: string | null; width?: number; height?: number; batchSize?: number; @@ -64,6 +69,27 @@ type FinalizedImageResult = { height?: number; }; +type EnhanceProStoredRequest = { + prompt: string; + sourceImageUrl: string; + width: number; + height: number; + batchSize: number; + model: EnhanceProKreaModel; + upscalingActivated: boolean; + imageScalingFactor: number; + chatId: string | null; + messageId: string | null; +}; + +type EnhanceProSuccessPayload = Awaited< + ReturnType +> & { + chargedPoints: number; +}; + +type EnhanceProTaskRecord = typeof enhanceProTask.$inferSelect; + function clampNumber(value: unknown, min: number, max: number, fallback: number) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; @@ -85,6 +111,10 @@ function normalizeJobStatus(value: unknown) { return String(value || "").trim().toLowerCase(); } +function normalizeClientRequestId(value: unknown) { + return String(value || "").trim().slice(0, 64); +} + function getEnhanceProKeyFingerprint(apiKey: string) { return createHash("sha256") .update(String(apiKey || "").trim()) @@ -126,14 +156,13 @@ function resolveEnhanceProApiKey( }; } -function buildEnhanceProTaskPendingResponse(taskId: string, taskKeyId?: string | null) { +function buildEnhanceProTaskPendingResponse(taskId: string) { return NextResponse.json( { success: true, processing: true, status: "running", taskId, - taskKeyId, pollAfterMs: ENHANCE_PRO_TASK_POLL_INTERVAL_MS, }, { status: 202 } @@ -252,7 +281,8 @@ async function resolveSourceInput(body: EnhanceProRequestBody) { } if (imageUrl) { - const hasRequestedDimensions = Number.isFinite(Number(body.width)) && Number.isFinite(Number(body.height)); + const hasRequestedDimensions = + Number.isFinite(Number(body.width)) && Number.isFinite(Number(body.height)); if (hasRequestedDimensions) { return { sourceImageUrl: imageUrl, @@ -351,12 +381,7 @@ async function createEnhanceProJob(params: { } } -async function pollEnhanceProJob( - config: EnhanceProKreaConfig, - jobId: string, - delay = ENHANCE_PRO_TASK_POLL_INTERVAL_MS, - timeout = ENHANCE_PRO_SYNC_WAIT_TIMEOUT_MS, -): Promise { +async function fetchEnhanceProJobStatus(config: EnhanceProKreaConfig, jobId: string) { const apiUrl = normalizeBaseUrl(config.baseUrl); const response = await fetch(`${apiUrl}/jobs/${jobId}`, { method: "GET", @@ -371,27 +396,7 @@ async function pollEnhanceProJob( throw new Error(`查询任务状态失败: ${response.status} ${detail || response.statusText}`); } - const payload = await response.json(); - const status = normalizeJobStatus(payload?.status); - - if (status === "completed") { - return payload; - } - - if (status === "failed" || status === "cancelled") { - throw new Error(`Krea 任务${status === "failed" ? "失败" : "已取消"}`); - } - - if (timeout <= 0) { - throw new Error("轮询超时"); - } - - await new Promise((resolve) => setTimeout(resolve, delay)); - return pollEnhanceProJob(config, jobId, delay, timeout - delay); -} - -function isEnhanceProTaskStillProcessing(error: unknown) { - return error instanceof Error && /轮询超时/i.test(error.message || ""); + return await response.json(); } async function saveGeneratedImageRecord(params: { @@ -534,6 +539,235 @@ async function finalizeEnhanceProTaskResult(params: { }; } +function parseEnhanceProStoredRequest(task: EnhanceProTaskRecord): EnhanceProStoredRequest { + const request = + task.request && typeof task.request === "object" + ? (task.request as Partial) + : {}; + + const width = sanitizeDimension(request.width, 1024); + const height = sanitizeDimension(request.height, 1024); + + return { + prompt: String(request.prompt || task.prompt || "").trim() || "图像增强 Pro", + sourceImageUrl: + String(request.sourceImageUrl || task.sourceImageUrl || "").trim(), + width, + height, + batchSize: clampInteger(request.batchSize, 1, 8, 1), + model: + request.model === "Recovery" || + request.model === "Recovery V2" || + request.model === "Reimagine" || + request.model === "Redefine" + ? request.model + : "Recovery V2", + upscalingActivated: normalizeBoolean(request.upscalingActivated, false), + imageScalingFactor: clampNumber(request.imageScalingFactor, 1, 8, 1), + chatId: request.chatId ? String(request.chatId) : task.chatId || null, + messageId: request.messageId ? String(request.messageId) : task.messageId || null, + }; +} + +async function getEnhanceProTaskForUser(params: { taskId: string; userId: string }) { + const rows = await db + .select() + .from(enhanceProTask) + .where(and(eq(enhanceProTask.id, params.taskId), eq(enhanceProTask.userId, params.userId))) + .limit(1); + return rows[0] ?? null; +} + +async function getEnhanceProTaskByClientRequestId(params: { + userId: string; + clientRequestId: string; +}) { + const rows = await db + .select() + .from(enhanceProTask) + .where( + and( + eq(enhanceProTask.userId, params.userId), + eq(enhanceProTask.clientRequestId, params.clientRequestId), + ), + ) + .limit(1); + return rows[0] ?? null; +} + +async function markEnhanceProTaskRunning(taskId: string) { + await db + .update(enhanceProTask) + .set({ + status: "running", + updatedAt: new Date(), + error: null, + }) + .where(eq(enhanceProTask.id, taskId)); +} + +async function claimEnhanceProTaskFinalization(taskId: string) { + const rows = await db + .update(enhanceProTask) + .set({ + status: "finalizing", + updatedAt: new Date(), + error: null, + }) + .where( + and( + eq(enhanceProTask.id, taskId), + or(eq(enhanceProTask.status, "pending"), eq(enhanceProTask.status, "running")), + ), + ) + .returning(); + return rows[0] ?? null; +} + +async function markEnhanceProTaskFailed(taskId: string, errorMessage: string) { + await db + .update(enhanceProTask) + .set({ + status: "failed", + error: errorMessage, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(enhanceProTask.id, taskId)); +} + +async function persistEnhanceProTaskSuccess(params: { + taskId: string; + payload: EnhanceProSuccessPayload; + pointsCharged: boolean; +}) { + await db + .update(enhanceProTask) + .set({ + status: "succeeded", + result: params.payload as Record, + error: null, + pointsCharged: params.pointsCharged, + chargedAt: params.pointsCharged ? new Date() : null, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(enhanceProTask.id, params.taskId)); +} + +async function consumeEnhanceProPointsOnce( + task: EnhanceProTaskRecord, + request: EnhanceProStoredRequest, +) { + if (task.pointsCost <= 0) { + return { ok: true, skipped: true }; + } + + const note = `图像增强 Pro 消耗积分(任务 ${task.id} / ${task.providerTaskId})`; + const existingLedger = await db + .select({ id: pointsLedger.id }) + .from(pointsLedger) + .where( + and( + eq(pointsLedger.userId, task.userId), + eq(pointsLedger.reason, "enhance_pro_generation"), + eq(pointsLedger.note, note), + ), + ) + .limit(1); + + if (existingLedger[0]) { + return { ok: true, skipped: true }; + } + + return await consumeUserPoints({ + userId: task.userId, + amount: task.pointsCost, + reason: "enhance_pro_generation", + modelId: `${ENHANCE_PRO_MODEL_ID}:${request.model}`, + chatId: request.chatId, + messageId: request.messageId, + note, + }); +} + +function getStoredEnhanceProSuccessPayload(task: EnhanceProTaskRecord) { + if (!task.result || typeof task.result !== "object") { + return null; + } + return task.result as EnhanceProSuccessPayload; +} + +async function finalizeEnhanceProTask( + task: EnhanceProTaskRecord, + taskResult: any, + startedAt?: number, +) { + const existingPayload = getStoredEnhanceProSuccessPayload(task); + if (task.status === "succeeded" && existingPayload) { + return existingPayload; + } + + const claimedTask = + task.status === "finalizing" ? task : await claimEnhanceProTaskFinalization(task.id); + if (!claimedTask) { + return null; + } + + const request = parseEnhanceProStoredRequest(claimedTask); + + try { + const responsePayload = await finalizeEnhanceProTaskResult({ + userId: claimedTask.userId, + taskResult, + prompt: request.prompt, + chatId: request.chatId, + messageId: request.messageId, + startedAt, + sourceImageUrls: request.sourceImageUrl ? [request.sourceImageUrl] : undefined, + width: request.width, + height: request.height, + }); + + let pointsCharged = claimedTask.pointsCharged; + if (!pointsCharged && claimedTask.pointsCost > 0) { + try { + const chargeResult = await consumeEnhanceProPointsOnce(claimedTask, request); + pointsCharged = chargeResult.ok || chargeResult.skipped; + } catch (pointsError) { + console.error("[Points] Failed to consume Enhance Pro points:", pointsError); + } + } + + const payload: EnhanceProSuccessPayload = { + ...responsePayload, + chargedPoints: pointsCharged ? claimedTask.pointsCost : 0, + }; + + await persistEnhanceProTaskSuccess({ + taskId: claimedTask.id, + payload, + pointsCharged, + }); + + return payload; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "未知错误"; + await markEnhanceProTaskFailed(claimedTask.id, errorMessage); + throw error; + } +} + +function buildEnhanceProTaskFailedResponse(task: EnhanceProTaskRecord) { + return NextResponse.json( + { + error: "图像增强 Pro 处理失败", + detail: String(task.error || "任务失败"), + }, + { status: 500 }, + ); +} + async function loadEnhanceProConfig() { const setting = await getSystemSetting({ key: ENHANCE_PRO_KREA_SETTING_KEY }); const existingValue = @@ -558,50 +792,76 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "缺少 taskId 参数" }, { status: 400 }); } + const task = await getEnhanceProTaskForUser({ + taskId, + userId: session.user.id, + }); + if (!task) { + return NextResponse.json({ error: "任务不存在" }, { status: 404 }); + } + + const storedPayload = getStoredEnhanceProSuccessPayload(task); + if (task.status === "succeeded" && storedPayload) { + return NextResponse.json(storedPayload); + } + if (task.status === "failed") { + return buildEnhanceProTaskFailedResponse(task); + } + if (task.status === "finalizing") { + return buildEnhanceProTaskPendingResponse(task.id); + } + const config = await loadEnhanceProConfig(); if (!config.enabled) { return NextResponse.json({ error: "图像增强 Pro 功能未开启" }, { status: 403 }); } - const keySelection = resolveEnhanceProApiKey(config, searchParams.get("taskKeyId")); + const keySelection = resolveEnhanceProApiKey(config, task.taskKeyId); if (!keySelection || !config.baseUrl) { return NextResponse.json({ error: "图像增强 Pro API 尚未配置" }, { status: 400 }); } - let taskResult: any; - try { - taskResult = await pollEnhanceProJob( - { ...config, apiKey: keySelection.apiKey }, - taskId - ); - } catch (pollError) { - if (isEnhanceProTaskStillProcessing(pollError)) { - return buildEnhanceProTaskPendingResponse(taskId, keySelection.keyId); + const taskResult = await fetchEnhanceProJobStatus( + { ...config, apiKey: keySelection.apiKey }, + task.providerTaskId, + ); + const providerStatus = normalizeJobStatus(taskResult?.status); + + if (providerStatus === "completed") { + const completedPayload = await finalizeEnhanceProTask(task, taskResult); + if (completedPayload) { + return NextResponse.json(completedPayload); } + + const refreshedTask = await getEnhanceProTaskForUser({ + taskId, + userId: session.user.id, + }); + const refreshedPayload = refreshedTask + ? getStoredEnhanceProSuccessPayload(refreshedTask) + : null; + if (refreshedTask?.status === "succeeded" && refreshedPayload) { + return NextResponse.json(refreshedPayload); + } + if (refreshedTask?.status === "failed") { + return buildEnhanceProTaskFailedResponse(refreshedTask); + } + return buildEnhanceProTaskPendingResponse(task.id); + } + + if (providerStatus === "failed" || providerStatus === "cancelled") { + const detail = `Krea 任务${providerStatus === "failed" ? "失败" : "已取消"}`; + await markEnhanceProTaskFailed(task.id, detail); return NextResponse.json( { error: "图像增强 Pro 处理失败", - detail: pollError instanceof Error ? pollError.message : "轮询失败", + detail, }, { status: 500 }, ); } - const width = sanitizeDimension(searchParams.get("width"), 1024); - const height = sanitizeDimension(searchParams.get("height"), 1024); - const responsePayload = await finalizeEnhanceProTaskResult({ - userId: session.user.id, - taskResult, - prompt: searchParams.get("prompt") || "图像增强 Pro", - chatId: searchParams.get("chatId"), - messageId: searchParams.get("messageId"), - sourceImageUrls: searchParams.get("sourceImageUrl") - ? [String(searchParams.get("sourceImageUrl"))] - : undefined, - width, - height, - }); - - return NextResponse.json(responsePayload); + await markEnhanceProTaskRunning(task.id); + return buildEnhanceProTaskPendingResponse(task.id); } catch (error) { console.error("[Enhance Pro API][TaskStatus] 错误:", error); return NextResponse.json( @@ -629,6 +889,24 @@ export async function POST(request: NextRequest) { const requestConfig = { ...config, apiKey: keySelection.apiKey }; const body = (await request.json()) as EnhanceProRequestBody; + const clientRequestId = normalizeClientRequestId(body.clientRequestId); + if (clientRequestId) { + const existingTask = await getEnhanceProTaskByClientRequestId({ + userId: session.user.id, + clientRequestId, + }); + if (existingTask) { + const existingPayload = getStoredEnhanceProSuccessPayload(existingTask); + if (existingTask.status === "succeeded" && existingPayload) { + return NextResponse.json(existingPayload); + } + if (existingTask.status === "failed") { + return buildEnhanceProTaskFailedResponse(existingTask); + } + return buildEnhanceProTaskPendingResponse(existingTask.id); + } + } + const prompt = String(body.prompt || "").trim(); const chatId = body.chatId || null; const messageId = body.messageId || null; @@ -706,78 +984,100 @@ export async function POST(request: NextRequest) { }); const createdStatus = normalizeJobStatus(createdJob?.status); - const taskId = String(createdJob?.job_id || "").trim(); - if (!taskId) { + const providerTaskId = String(createdJob?.job_id || "").trim(); + if (!providerTaskId) { return NextResponse.json( { error: "创建图像增强 Pro 任务失败", detail: "Krea 未返回 job_id" }, { status: 500 }, ); } - if (pointsEstimate.chargeable) { - try { - await consumeUserPoints({ + const storedRequest: EnhanceProStoredRequest = { + prompt: prompt || "图像增强 Pro", + sourceImageUrl: sourceInput.sourceImageUrl, + width: clampedDimensions.width, + height: clampedDimensions.height, + batchSize, + model: selectedModel, + upscalingActivated, + imageScalingFactor, + chatId, + messageId, + }; + + let createdTask: EnhanceProTaskRecord | null = null; + try { + const inserted = await db + .insert(enhanceProTask) + .values({ userId: session.user.id, - amount: pointsEstimate.cost, - reason: "enhance_pro_generation", - modelId: `${ENHANCE_PRO_MODEL_ID}:${selectedModel}`, chatId, messageId, - note: "图像增强 Pro 消耗积分", + clientRequestId: clientRequestId || null, + providerTaskId, + taskKeyId: keySelection.keyId, + status: + createdStatus === "completed" + ? "running" + : createdStatus === "failed" || createdStatus === "cancelled" + ? "failed" + : "pending", + prompt: storedRequest.prompt, + sourceImageUrl: storedRequest.sourceImageUrl, + request: storedRequest as unknown as Record, + error: + createdStatus === "failed" || createdStatus === "cancelled" + ? `Krea 任务${createdStatus === "failed" ? "失败" : "已取消"}` + : null, + pointsCost: pointsEstimate.chargeable ? pointsEstimate.cost : 0, + pointsCharged: false, + completedAt: + createdStatus === "failed" || createdStatus === "cancelled" ? new Date() : null, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + createdTask = inserted[0] ?? null; + } catch (insertError) { + if (clientRequestId) { + const existingTask = await getEnhanceProTaskByClientRequestId({ + userId: session.user.id, + clientRequestId, }); - } catch (pointsError) { - console.error("[Points] Failed to consume Enhance Pro points:", pointsError); + if (existingTask) { + const existingPayload = getStoredEnhanceProSuccessPayload(existingTask); + if (existingTask.status === "succeeded" && existingPayload) { + return NextResponse.json(existingPayload); + } + if (existingTask.status === "failed") { + return buildEnhanceProTaskFailedResponse(existingTask); + } + return buildEnhanceProTaskPendingResponse(existingTask.id); + } } + throw insertError; } - if (createdStatus === "completed") { - const responsePayload = await finalizeEnhanceProTaskResult({ - userId: session.user.id, - taskResult: createdJob, - prompt, - chatId, - messageId, - startedAt: startTime, - sourceImageUrls: [sourceInput.sourceImageUrl], - width: clampedDimensions.width, - height: clampedDimensions.height, - }); - return NextResponse.json({ - ...responsePayload, - chargedPoints: pointsEstimate.chargeable ? pointsEstimate.cost : 0, - }); - } - - try { - const taskResult = await pollEnhanceProJob(requestConfig, taskId); - const responsePayload = await finalizeEnhanceProTaskResult({ - userId: session.user.id, - taskResult, - prompt, - chatId, - messageId, - startedAt: startTime, - sourceImageUrls: [sourceInput.sourceImageUrl], - width: clampedDimensions.width, - height: clampedDimensions.height, - }); - return NextResponse.json({ - ...responsePayload, - chargedPoints: pointsEstimate.chargeable ? pointsEstimate.cost : 0, - }); - } catch (pollError) { - if (isEnhanceProTaskStillProcessing(pollError)) { - return buildEnhanceProTaskPendingResponse(taskId, keySelection.keyId); - } - + if (!createdTask) { return NextResponse.json( - { - error: "图像增强 Pro 处理失败", - detail: pollError instanceof Error ? pollError.message : "轮询失败", - }, + { error: "创建图像增强 Pro 任务失败", detail: "任务入库失败" }, { status: 500 }, ); } + + if (createdStatus === "completed") { + const responsePayload = await finalizeEnhanceProTask(createdTask, createdJob, startTime); + if (responsePayload) { + return NextResponse.json(responsePayload); + } + return buildEnhanceProTaskPendingResponse(createdTask.id); + } + + if (createdStatus === "failed" || createdStatus === "cancelled") { + return buildEnhanceProTaskFailedResponse(createdTask); + } + + return buildEnhanceProTaskPendingResponse(createdTask.id); } catch (error) { console.error("[Enhance Pro API] 错误:", error); return NextResponse.json( diff --git a/components/canvas/EnhanceProWorkspace.tsx b/components/canvas/EnhanceProWorkspace.tsx index 9bbd6225f..852389d9b 100644 --- a/components/canvas/EnhanceProWorkspace.tsx +++ b/components/canvas/EnhanceProWorkspace.tsx @@ -60,6 +60,46 @@ import { const fetcher = (url: string) => fetch(url).then((res) => res.json()); +function createEnhanceProClientRequestId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `enhance-pro-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function shouldPreserveEnhanceProRequestId(message: string) { + return /任务.*已提交|避免重复提交|稍后重试查看结果|failed to fetch|network/i.test( + String(message || "") + ); +} + +function buildEnhanceProRequestSignature(params: EnhanceProRequestParams) { + return JSON.stringify({ + imageUrl: params.imageUrl || null, + image_base64: params.image_base64 || null, + mimeType: params.mimeType || null, + prompt: params.prompt || "", + width: params.width, + height: params.height, + batchSize: params.batchSize || 1, + seed: params.seed || 0, + output_format: params.output_format || null, + subject_detection: params.subject_detection || null, + face_enhancement: params.face_enhancement === true, + face_enhancement_creativity: params.face_enhancement_creativity ?? null, + face_enhancement_strength: params.face_enhancement_strength ?? null, + crop_to_fill: params.crop_to_fill === true, + model: params.model || null, + upscaling_activated: params.upscaling_activated === true, + image_scaling_factor: params.image_scaling_factor ?? 1, + creativity: params.creativity ?? null, + texture: params.texture ?? null, + sharpen: params.sharpen ?? null, + denoise: params.denoise ?? null, + detail: params.detail ?? null, + }); +} + type EnhanceProClientConfig = { enabled: boolean; providerName: string; @@ -398,6 +438,8 @@ export function EnhanceProWorkspace({ const [faceOpen, setFaceOpen] = useState(true); const [comparePosition, setComparePosition] = useState(50); const compareDragPointerIdRef = useRef(null); + const requestIdRef = useRef(null); + const requestSignatureRef = useRef(null); const { data: configPayload, isLoading } = useSWR<{ success?: boolean; @@ -552,50 +594,61 @@ export function EnhanceProWorkspace({ setIsSubmitting(true); setLastErrorMessage(""); + const requestParams: EnhanceProRequestParams = { + ...sourcePayload, + prompt: formState.prompt.trim() || "图像增强 Pro", + width: clampNumber( + Math.round(formState.width), + 1, + config.maxOutputEdge + ), + height: clampNumber( + Math.round(formState.height), + 1, + config.maxOutputEdge + ), + batchSize: config.allowBatchSize + ? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize) + : 1, + seed: Math.round(formState.seed), + output_format: formState.output_format, + subject_detection: formState.subject_detection, + face_enhancement: formState.face_enhancement, + face_enhancement_creativity: clampNumber( + formState.face_enhancement_creativity, + 0, + 1 + ), + face_enhancement_strength: clampNumber( + formState.face_enhancement_strength, + 0, + 1 + ), + crop_to_fill: formState.crop_to_fill, + model: formState.model, + upscaling_activated: formState.upscaling_activated, + image_scaling_factor: clampNumber( + formState.image_scaling_factor, + 1, + config.maxUpscaleFactor + ), + creativity: clampNumber(Math.round(formState.creativity), 1, 6), + texture: clampNumber(Math.round(formState.texture), 1, 5), + sharpen: clampNumber(formState.sharpen, 0, 1), + denoise: clampNumber(formState.denoise, 0, 1), + detail: clampNumber(formState.detail, 0, 1), + }; + const requestSignature = buildEnhanceProRequestSignature(requestParams); + const clientRequestId = + requestIdRef.current && requestSignatureRef.current === requestSignature + ? requestIdRef.current + : createEnhanceProClientRequestId(); + requestIdRef.current = clientRequestId; + requestSignatureRef.current = requestSignature; try { const result = await imageGenerationService.enhanceImagePro({ - ...sourcePayload, - prompt: formState.prompt.trim() || "图像增强 Pro", - width: clampNumber( - Math.round(formState.width), - 1, - config.maxOutputEdge - ), - height: clampNumber( - Math.round(formState.height), - 1, - config.maxOutputEdge - ), - batchSize: config.allowBatchSize - ? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize) - : 1, - seed: Math.round(formState.seed), - output_format: formState.output_format, - subject_detection: formState.subject_detection, - face_enhancement: formState.face_enhancement, - face_enhancement_creativity: clampNumber( - formState.face_enhancement_creativity, - 0, - 1 - ), - face_enhancement_strength: clampNumber( - formState.face_enhancement_strength, - 0, - 1 - ), - crop_to_fill: formState.crop_to_fill, - model: formState.model, - upscaling_activated: formState.upscaling_activated, - image_scaling_factor: clampNumber( - formState.image_scaling_factor, - 1, - config.maxUpscaleFactor - ), - creativity: clampNumber(Math.round(formState.creativity), 1, 6), - texture: clampNumber(Math.round(formState.texture), 1, 5), - sharpen: clampNumber(formState.sharpen, 0, 1), - denoise: clampNumber(formState.denoise, 0, 1), - detail: clampNumber(formState.detail, 0, 1), + ...requestParams, + clientRequestId, }); if (!result.success || !result.data?.images?.length) { @@ -604,12 +657,18 @@ export function EnhanceProWorkspace({ setGeneratedImages(result.data.images); setSelectedResultIndex(0); + requestIdRef.current = null; + requestSignatureRef.current = null; toast.success( `图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果` ); } catch (error) { const message = error instanceof Error ? error.message : "图像增强 Pro 失败"; + if (!shouldPreserveEnhanceProRequestId(message)) { + requestIdRef.current = null; + requestSignatureRef.current = null; + } setLastErrorMessage(message); toast.error(message); } finally { diff --git a/components/enhance-pro-dialog.tsx b/components/enhance-pro-dialog.tsx index f640e7974..46d67948c 100644 --- a/components/enhance-pro-dialog.tsx +++ b/components/enhance-pro-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { Loader2, Sparkles } from "lucide-react"; import { toast } from "sonner"; @@ -30,6 +30,44 @@ import { imageGenerationService, type EnhanceProImageResult } from "@/services/c const fetcher = (url: string) => fetch(url).then((res) => res.json()); +function createEnhanceProClientRequestId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `enhance-pro-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function shouldPreserveEnhanceProRequestId(message: string) { + return /任务.*已提交|避免重复提交|稍后重试查看结果|failed to fetch|network/i.test( + String(message || "") + ); +} + +function buildEnhanceProRequestSignature(params: { + imageUrl: string; + prompt: string; + width: number; + height: number; + batchSize: number; + seed: number; + output_format: EnhanceProOutputFormat; + subject_detection: EnhanceProSubjectDetection; + face_enhancement: boolean; + face_enhancement_creativity: number; + face_enhancement_strength: number; + crop_to_fill: boolean; + model: EnhanceProKreaModel; + upscaling_activated: boolean; + image_scaling_factor: number; + creativity: number; + texture: number; + sharpen: number; + denoise: number; + detail: number; +}) { + return JSON.stringify(params); +} + type EnhanceProClientConfig = { enabled: boolean; providerName: string; @@ -196,6 +234,8 @@ export function EnhanceProDialog({ const [isSubmitting, setIsSubmitting] = useState(false); const [naturalSize, setNaturalSize] = useState({ width: 1024, height: 1024 }); const [selectedPresetId, setSelectedPresetId] = useState(""); + const requestIdRef = useRef(null); + const requestSignatureRef = useRef(null); const [formState, setFormState] = useState(() => createDefaultFormState(FALLBACK_CONFIG, 1024, 1024), ); @@ -262,34 +302,45 @@ export function EnhanceProDialog({ } setIsSubmitting(true); + const requestParams = { + imageUrl, + prompt: formState.prompt.trim() || "图像增强 Pro", + width: clampNumber(Math.round(formState.width), 1, config.maxOutputEdge), + height: clampNumber(Math.round(formState.height), 1, config.maxOutputEdge), + batchSize: config.allowBatchSize + ? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize) + : 1, + seed: Math.round(formState.seed), + output_format: formState.output_format, + subject_detection: formState.subject_detection, + face_enhancement: formState.face_enhancement, + face_enhancement_creativity: clampNumber(formState.face_enhancement_creativity, 0, 1), + face_enhancement_strength: clampNumber(formState.face_enhancement_strength, 0, 1), + crop_to_fill: formState.crop_to_fill, + model: formState.model, + upscaling_activated: formState.upscaling_activated, + image_scaling_factor: clampNumber( + formState.image_scaling_factor, + 1, + config.maxUpscaleFactor + ), + creativity: clampNumber(Math.round(formState.creativity), 1, 6), + texture: clampNumber(Math.round(formState.texture), 1, 5), + sharpen: clampNumber(formState.sharpen, 0, 1), + denoise: clampNumber(formState.denoise, 0, 1), + detail: clampNumber(formState.detail, 0, 1), + }; + const requestSignature = buildEnhanceProRequestSignature(requestParams); + const clientRequestId = + requestIdRef.current && requestSignatureRef.current === requestSignature + ? requestIdRef.current + : createEnhanceProClientRequestId(); + requestIdRef.current = clientRequestId; + requestSignatureRef.current = requestSignature; try { const result = await imageGenerationService.enhanceImagePro({ - imageUrl, - prompt: formState.prompt.trim() || "图像增强 Pro", - width: clampNumber(Math.round(formState.width), 1, config.maxOutputEdge), - height: clampNumber(Math.round(formState.height), 1, config.maxOutputEdge), - batchSize: config.allowBatchSize - ? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize) - : 1, - seed: Math.round(formState.seed), - output_format: formState.output_format, - subject_detection: formState.subject_detection, - face_enhancement: formState.face_enhancement, - face_enhancement_creativity: clampNumber(formState.face_enhancement_creativity, 0, 1), - face_enhancement_strength: clampNumber(formState.face_enhancement_strength, 0, 1), - crop_to_fill: formState.crop_to_fill, - model: formState.model, - upscaling_activated: formState.upscaling_activated, - image_scaling_factor: clampNumber( - formState.image_scaling_factor, - 1, - config.maxUpscaleFactor - ), - creativity: clampNumber(Math.round(formState.creativity), 1, 6), - texture: clampNumber(Math.round(formState.texture), 1, 5), - sharpen: clampNumber(formState.sharpen, 0, 1), - denoise: clampNumber(formState.denoise, 0, 1), - detail: clampNumber(formState.detail, 0, 1), + ...requestParams, + clientRequestId, }); if (!result.success || !result.data?.images?.length) { @@ -297,10 +348,17 @@ export function EnhanceProDialog({ } onComplete?.(result.data.images); + requestIdRef.current = null; + requestSignatureRef.current = null; toast.success(`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`); onOpenChange(false); } catch (error) { - toast.error(error instanceof Error ? error.message : "图像增强 Pro 失败"); + const message = error instanceof Error ? error.message : "图像增强 Pro 失败"; + if (!shouldPreserveEnhanceProRequestId(message)) { + requestIdRef.current = null; + requestSignatureRef.current = null; + } + toast.error(message); } finally { setIsSubmitting(false); } diff --git a/lib/client/long-task.ts b/lib/client/long-task.ts index 38bbfcdf1..cd7358837 100644 --- a/lib/client/long-task.ts +++ b/lib/client/long-task.ts @@ -30,6 +30,14 @@ function buildTaskError(payload: LongTaskResponse | null | undefined, fallback: return new Error(String(message || fallback)); } +function buildTaskInterruptedError(taskId: string) { + const error = new Error( + "任务已提交,网络连接暂时中断,请稍后重试查看结果,避免重复提交。" + ) as Error & { taskId?: string }; + error.taskId = taskId; + return error; +} + async function safeParseJson(response: Response) { try { return (await response.json()) as LongTaskResponse; @@ -132,9 +140,7 @@ export async function requestLongTaskResult(params: { } catch (error) { consecutivePollFailures += 1; if (consecutivePollFailures >= maxPollFailures) { - throw error instanceof Error - ? error - : new Error("查询任务状态失败"); + throw buildTaskInterruptedError(taskId); } pollAfterMs = resolveRetryAfterMs(null, null, pollAfterMs, consecutivePollFailures); continue; diff --git a/lib/db/migrations/0074_add_enhance_pro_tasks.sql b/lib/db/migrations/0074_add_enhance_pro_tasks.sql new file mode 100644 index 000000000..27106e0e2 --- /dev/null +++ b/lib/db/migrations/0074_add_enhance_pro_tasks.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS "EnhanceProTask" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade, + "chatId" uuid REFERENCES "Chat"("id") ON DELETE cascade, + "messageId" uuid, + "clientRequestId" varchar(64), + "providerTaskId" varchar(128) NOT NULL, + "taskKeyId" varchar(64), + "status" varchar(16) NOT NULL DEFAULT 'pending', + "prompt" text, + "sourceImageUrl" text NOT NULL, + "request" jsonb NOT NULL, + "result" jsonb, + "error" text, + "pointsCost" integer NOT NULL DEFAULT 0, + "pointsCharged" boolean NOT NULL DEFAULT false, + "chargedAt" timestamp, + "completedAt" timestamp, + "createdAt" timestamp NOT NULL DEFAULT now(), + "updatedAt" timestamp NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "EnhanceProTask_providerTaskId_idx" + ON "EnhanceProTask" ("providerTaskId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "EnhanceProTask_userId_clientRequestId_idx" + ON "EnhanceProTask" ("userId", "clientRequestId"); diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index bd8ae33db..06fcd4c57 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -421,6 +421,13 @@ "when": 1776336730695, "tag": "0073_add_system_setting_history", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1777252800000, + "tag": "0074_add_enhance_pro_tasks", + "breakpoints": true } ] } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index cd29d2591..bb0752469 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -751,6 +751,34 @@ export const canvasImageTask = pgTable("CanvasImageTask", { export type CanvasImageTask = InferSelectModel; +export const enhanceProTask = pgTable("EnhanceProTask", { + id: uuid("id").primaryKey().notNull().defaultRandom(), + userId: uuid("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + chatId: uuid("chatId").references(() => chat.id, { onDelete: "cascade" }), + messageId: uuid("messageId"), + clientRequestId: varchar("clientRequestId", { length: 64 }), + providerTaskId: varchar("providerTaskId", { length: 128 }).notNull(), + taskKeyId: varchar("taskKeyId", { length: 64 }), + status: varchar("status", { length: 16 }) + .notNull() + .default("pending"), // pending | running | finalizing | succeeded | failed + prompt: text("prompt"), + sourceImageUrl: text("sourceImageUrl").notNull(), + request: jsonb("request").notNull(), + result: jsonb("result"), + error: text("error"), + pointsCost: integer("pointsCost").notNull().default(0), + pointsCharged: boolean("pointsCharged").notNull().default(false), + chargedAt: timestamp("chargedAt"), + completedAt: timestamp("completedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow(), +}); + +export type EnhanceProTask = InferSelectModel; + export const agentPsdRenderTask = pgTable("AgentPsdRenderTask", { id: uuid("id").primaryKey().notNull().defaultRandom(), userId: uuid("userId") diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 62115448a..145ffb332 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -65,6 +65,7 @@ export interface EnhanceProRequestParams { prompt?: string; chatId?: string | null; messageId?: string | null; + clientRequestId?: string | null; width: number; height: number; batchSize?: number; @@ -388,14 +389,6 @@ export const imageGenerationService = { const payload = await requestLongTaskResult({ endpoint: '/api/enhance/pro', payload: params, - pollingParams: { - prompt: params.prompt || '图像增强 Pro', - chatId: params.chatId, - messageId: params.messageId, - sourceImageUrl: params.imageUrl, - width: params.width, - height: params.height, - }, }); if (!payload?.success) { @@ -440,6 +433,12 @@ export const imageGenerationService = { }; } catch (error) { console.error('[Canvas] 图像增强 Pro 失败:', error); + if (error instanceof Error && /failed to fetch|network/i.test(error.message)) { + return { + success: false, + error: '任务可能已提交,网络连接暂时中断,请稍后重试查看结果,避免重复提交。', + }; + } return { success: false, error: error instanceof Error ? error.message : '图像增强 Pro 失败',