From 5e155ecf3c15b8241493967e4c6f64a5b78cac03 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 4 Mar 2026 20:00:37 +0800 Subject: [PATCH] fix: reduce 504 via async polling and chat keepalive --- app/(chat)/api/chat/route.ts | 169 +++++++++++---- app/(chat)/gallery/page.tsx | 25 +-- app/api/enhance/route.ts | 330 ++++++++++++++++-------------- app/api/watermark-remove/route.ts | 204 ++++++++++++------ components/chat.tsx | 46 ++--- components/image-annotator.tsx | 44 ++-- lib/client/long-task.ts | 91 ++++++++ 7 files changed, 579 insertions(+), 330 deletions(-) create mode 100644 lib/client/long-task.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 6f1547656..fd190b95c 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1661,8 +1661,11 @@ let globalStreamContext: ResumableStreamContext | null = null; const inFlightChatRequests = new Map(); const IN_FLIGHT_REQUEST_TTL_MS = 5 * 60 * 1000; const DUPLICATE_REQUEST_WAIT_MS = 2 * 60 * 1000; -const SMART_AUTO_GRAPH_TIMEOUT_MS = 120000; -const SMART_AUTO_FALLBACK_TIMEOUT_MS = 120000; +const CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS = 55_000; +const SMART_AUTO_GRAPH_TIMEOUT_MS = 35_000; +const SMART_AUTO_FALLBACK_TIMEOUT_MS = 20_000; +const SMART_AUTO_IMAGE_SUMMARY_TIMEOUT_MS = 18_000; +const DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS = 12_000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -1696,6 +1699,26 @@ async function withOperationTimeout( } } +function getRemainingBudgetMs(startedAt: number, budgetMs: number) { + return Math.max(0, budgetMs - (Date.now() - startedAt)); +} + +function getBudgetedTimeout(params: { + startedAt: number; + preferredMs: number; + minimumMs?: number; +}) { + const minimumMs = params.minimumMs ?? 6_000; + const remainingMs = getRemainingBudgetMs( + params.startedAt, + CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS + ); + if (remainingMs < minimumMs) { + return null; + } + return Math.min(params.preferredMs, remainingMs); +} + const getTokenlensCatalog = cache( async (): Promise => { try { @@ -1764,6 +1787,7 @@ export async function POST(request: Request) { autoContext?: string; deepThinkingEnabled?: boolean; } = requestBody; + const requestStartedAt = Date.now(); const now = Date.now(); for (const [key, startedAt] of inFlightChatRequests.entries()) { @@ -2040,6 +2064,17 @@ export async function POST(request: Request) { // 2. 使用 LangGraph 驱动的自动模式工作流 const { runAutoAgentGraph } = await import("@/lib/ai/auto-agent-graph"); + const autoGraphTimeoutMs = getBudgetedTimeout({ + startedAt: requestStartedAt, + preferredMs: SMART_AUTO_GRAPH_TIMEOUT_MS, + minimumMs: 8_000, + }); + if (!autoGraphTimeoutMs) { + throw new OperationTimeoutError( + "runAutoAgentGraph.preResponseBudget", + CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS + ); + } const autoGraphResult = await withOperationTimeout( runAutoAgentGraph({ text: textContent, @@ -2051,7 +2086,7 @@ export async function POST(request: Request) { preferredModelId: preferredAgentModelId, }), "runAutoAgentGraph", - SMART_AUTO_GRAPH_TIMEOUT_MS, + autoGraphTimeoutMs, ); if (autoGraphResult.analysis) { @@ -2214,26 +2249,35 @@ export async function POST(request: Request) { const isAutoTimeout = autoError instanceof OperationTimeoutError; if (isAutoTimeout) { console.warn( - `[Smart Auto Mode] ⏱️ Auto-agent timed out after ${SMART_AUTO_GRAPH_TIMEOUT_MS}ms, using local fallback`, + `[Smart Auto Mode] ⏱️ Auto-agent timed out or exceeded pre-response budget (${CHAT_PRE_RESPONSE_SOFT_TIMEOUT_MS}ms), using local fallback`, ); } try { const { analyzeIntent } = await import("@/lib/ai/smart-analyzer"); - const rescueAnalysis = await withOperationTimeout( - analyzeIntent({ - text: autoTextForFallback, - hasImage: autoHasImageForFallback || smartAutoHistoryImages.length > 0, - imageUrls: smartAutoHistoryImages, - imageFromHistory: autoFromHistoryForFallback || smartAutoHistoryImages.length > 0, - historyMessages: [], - context: undefined, - preferredModelId: preferredAgentModelId, - }), - "fallbackAnalyzeIntent", - SMART_AUTO_FALLBACK_TIMEOUT_MS, - ); - if (rescueAnalysis.conversationalReply?.trim()) { - smartAutoFriendlyMessage = rescueAnalysis.conversationalReply.trim(); + const rescueTimeoutMs = getBudgetedTimeout({ + startedAt: requestStartedAt, + preferredMs: SMART_AUTO_FALLBACK_TIMEOUT_MS, + minimumMs: 5_000, + }); + if (rescueTimeoutMs) { + const rescueAnalysis = await withOperationTimeout( + analyzeIntent({ + text: autoTextForFallback, + hasImage: autoHasImageForFallback || smartAutoHistoryImages.length > 0, + imageUrls: smartAutoHistoryImages, + imageFromHistory: autoFromHistoryForFallback || smartAutoHistoryImages.length > 0, + historyMessages: [], + context: undefined, + preferredModelId: preferredAgentModelId, + }), + "fallbackAnalyzeIntent", + rescueTimeoutMs, + ); + if (rescueAnalysis.conversationalReply?.trim()) { + smartAutoFriendlyMessage = rescueAnalysis.conversationalReply.trim(); + } + } else { + console.warn("[Smart Auto Mode] Skip rescue analysis due to pre-response budget"); } } catch (rescueError) { console.error("[Smart Auto Mode] Rescue analysis failed:", rescueError); @@ -2265,18 +2309,27 @@ export async function POST(request: Request) { if (summaryImageUrls.length > 0) { const { summarizeImageForExecutionReport } = await import("@/lib/ai/smart-analyzer"); - const summary = await withOperationTimeout( - summarizeImageForExecutionReport({ - text: autoTextForFallback, - imageUrls: summaryImageUrls, - context: typeof autoContext === "string" ? autoContext : undefined, - preferredModelId: preferredAgentModelId, - }), - "fallbackVisualSummary", - SMART_AUTO_FALLBACK_TIMEOUT_MS, - ); - if (summary?.trim()) { - fallbackVisualSummary = summary.trim(); + const fallbackSummaryTimeoutMs = getBudgetedTimeout({ + startedAt: requestStartedAt, + preferredMs: SMART_AUTO_IMAGE_SUMMARY_TIMEOUT_MS, + minimumMs: 5_000, + }); + if (fallbackSummaryTimeoutMs) { + const summary = await withOperationTimeout( + summarizeImageForExecutionReport({ + text: autoTextForFallback, + imageUrls: summaryImageUrls, + context: typeof autoContext === "string" ? autoContext : undefined, + preferredModelId: preferredAgentModelId, + }), + "fallbackVisualSummary", + fallbackSummaryTimeoutMs, + ); + if (summary?.trim()) { + fallbackVisualSummary = summary.trim(); + } + } else { + console.warn("[Smart Auto Mode] Skip fallback visual summary due to pre-response budget"); } } } catch (summaryError) { @@ -2496,8 +2549,12 @@ export async function POST(request: Request) { .join(" "); let summary: string | null = null; - const summaryTimeouts = [120000]; - for (const timeoutMs of summaryTimeouts) { + const summaryTimeoutMs = getBudgetedTimeout({ + startedAt: requestStartedAt, + preferredMs: SMART_AUTO_IMAGE_SUMMARY_TIMEOUT_MS, + minimumMs: 6_000, + }); + if (summaryTimeoutMs) { summary = await withOperationTimeout( summarizeImageForExecutionReport({ text: latestUserText, @@ -2506,9 +2563,10 @@ export async function POST(request: Request) { preferredModelId: preferredAgentModelId, }), "enrichImageAnalysisSummary", - timeoutMs, + summaryTimeoutMs, ); - if (summary?.trim()) break; + } else { + console.warn("[Smart Auto Mode] Skip image summary enrichment due to pre-response budget"); } if (summary?.trim()) { @@ -4158,11 +4216,27 @@ export async function POST(request: Request) { } // ========== 千问/FAL 视频处理结束 ========== + let stopStreamKeepalive: (() => void) | null = null; const stream = createUIMessageStream({ execute: async ({ writer: dataStream }) => { - // 构建工具对象,只包含启用的工具 - const tools: any = {}; - const activeTools: string[] = []; + const stopDefaultKeepalive = startStreamKeepalive( + dataStream, + "chat-default", + DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS + ); + stopStreamKeepalive = stopDefaultKeepalive; + safeWriteStreamChunk( + dataStream, + { + type: "data-keepalive", + data: JSON.stringify({ ts: Date.now(), label: "chat-default:init" }), + }, + "chat-default:init" + ); + try { + // 构建工具对象,只包含启用的工具 + const tools: any = {}; + const activeTools: string[] = []; if (enabledTools.includes("getWeather")) { tools.getWeather = getWeather; @@ -5049,16 +5123,21 @@ export async function POST(request: Request) { }, }); - result.consumeStream(); + result.consumeStream(); - dataStream.merge( - result.toUIMessageStream({ - sendReasoning: true, - }) - ); + dataStream.merge( + result.toUIMessageStream({ + sendReasoning: true, + }) + ); + } finally { + stopStreamKeepalive = stopDefaultKeepalive; + } }, generateId: generateUUID, onFinish: async ({ messages }) => { + stopStreamKeepalive?.(); + stopStreamKeepalive = null; await saveMessages({ messages: messages.map((currentMessage) => ({ id: currentMessage.id, @@ -5126,6 +5205,8 @@ export async function POST(request: Request) { } }, onError: () => { + stopStreamKeepalive?.(); + stopStreamKeepalive = null; return "Oops, an error occurred!"; }, }); diff --git a/app/(chat)/gallery/page.tsx b/app/(chat)/gallery/page.tsx index 72b63235d..a1425d28a 100644 --- a/app/(chat)/gallery/page.tsx +++ b/app/(chat)/gallery/page.tsx @@ -8,6 +8,7 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR import { Input } from "@/components/ui/input"; import Image from "next/image"; import { toast } from "sonner"; +import { requestLongTaskResult } from "@/lib/client/long-task"; import { formatBeijingTime } from "@/lib/utils"; type GalleryImage = { @@ -176,26 +177,20 @@ export default function GalleryPage() { try { const base64Data = (reader.result as string).split(",")[1]; - // 调用变清晰API - const enhanceResponse = await fetch("/api/enhance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const result = await requestLongTaskResult({ + endpoint: "/api/enhance", + payload: { image_base64: base64Data, prompt: `图片变清晰处理 - ${image.alt}`, chatId: image.chatId, messageId: image.messageId, - }), + }, + pollingParams: { + prompt: `图片变清晰处理 - ${image.alt}`, + chatId: image.chatId, + messageId: image.messageId, + }, }); - - if (!enhanceResponse.ok) { - const error = await enhanceResponse.json(); - throw new Error(error.error || error.detail || "变清晰失败"); - } - - const result = await enhanceResponse.json(); // 刷新相册列表以显示新图片 await fetchImages(pagination.page); diff --git a/app/api/enhance/route.ts b/app/api/enhance/route.ts index 800007e45..d3c4ce1e3 100644 --- a/app/api/enhance/route.ts +++ b/app/api/enhance/route.ts @@ -22,6 +22,8 @@ type EnhanceApiConfig = { const AOS_MAX_RESOLUTION = 4096; const ENHANCE_CREATE_TIMEOUT_MS = 120_000; const ENHANCE_CREATE_RETRY_COUNT = 1; +const ENHANCE_SYNC_WAIT_TIMEOUT_MS = 20_000; +const ENHANCE_TASK_POLL_INTERVAL_MS = 1200; function getAosFriendlyErrorMessage(detail: string): string | null { const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i); @@ -380,6 +382,166 @@ async function pollTaskResult( } } +function isEnhanceTaskStillProcessing(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return /轮询超时/i.test(error.message || ""); +} + +function buildEnhanceTaskPendingResponse(taskId: string) { + return NextResponse.json( + { + success: true, + processing: true, + status: "running", + taskId, + pollAfterMs: ENHANCE_TASK_POLL_INTERVAL_MS, + }, + { status: 202 } + ); +} + +async function finalizeEnhanceTaskResult(params: { + userId: string; + taskResult: any; + prompt?: string; + chatId?: string | null; + messageId?: string | null; + startedAt?: number; +}) { + if (params.taskResult?.status !== 200 || !params.taskResult?.data) { + throw new Error(params.taskResult?.message || "未知错误"); + } + + let enhancedImageBase64: string | null = null; + if (typeof params.taskResult.data.image_base64 === "string" && params.taskResult.data.image_base64) { + enhancedImageBase64 = params.taskResult.data.image_base64; + } else if (typeof params.taskResult.data.image === "string" && params.taskResult.data.image) { + const imageData = params.taskResult.data.image; + if (/^https?:\/\//i.test(imageData)) { + const imageResponse = await fetch(imageData); + if (!imageResponse.ok) { + throw new Error(`无法下载处理后的图片: ${imageResponse.status}`); + } + const downloadedBuffer = await imageResponse.arrayBuffer(); + enhancedImageBase64 = Buffer.from(downloadedBuffer).toString("base64"); + } else { + enhancedImageBase64 = imageData; + } + } + + if (!enhancedImageBase64) { + throw new Error("轮询结果未包含图片数据"); + } + + const uploadResult = await uploadBase64ToS3(enhancedImageBase64, "image/jpeg"); + const finalPrompt = params.prompt?.trim() || "图片变清晰处理"; + + try { + await db.insert(generatedImage).values({ + userId: params.userId, + chatId: params.chatId || null, + messageId: params.messageId || null, + thumbnailUrl: uploadResult.thumbnail, + originalUrl: uploadResult.original, + imageSize: "2K", + prompt: finalPrompt, + modelId: "enhance-1.0", + }); + } catch (dbError) { + console.error("[Enhance] 保存到相册失败(已忽略):", dbError); + } + + const processingTime = + typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined; + + return { + success: true, + image_base64: enhancedImageBase64, + thumbnail_url: uploadResult.thumbnail, + original_url: uploadResult.original, + processing_time: processingTime, + }; +} + +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const taskId = String(searchParams.get("taskId") || "").trim(); + if (!taskId) { + return NextResponse.json({ error: "缺少 taskId 参数" }, { status: 400 }); + } + + const prompt = searchParams.get("prompt") || "图片变清晰处理"; + const chatId = searchParams.get("chatId"); + const messageId = searchParams.get("messageId"); + + const enhanceConfig = await getSystemSetting({ key: "enhance_api_config" }); + if (!enhanceConfig?.value) { + return NextResponse.json( + { error: "变清晰API配置未设置,请在系统设置中配置" }, + { status: 400 } + ); + } + + const config = enhanceConfig.value as EnhanceApiConfig; + if (config.useBackupApi) { + return NextResponse.json( + { error: "当前启用了Real-ESRGAN备用模式,不支持 taskId 轮询" }, + { status: 400 } + ); + } + if (!config.apiUrl || !config.apiKey) { + return NextResponse.json( + { error: "变清晰API地址或密钥未配置" }, + { status: 400 } + ); + } + + const apiUrl = config.apiUrl.endsWith("/") ? config.apiUrl.slice(0, -1) : config.apiUrl; + + let taskResult: any; + try { + taskResult = await pollTaskResult( + apiUrl, + config.apiKey, + taskId, + ENHANCE_TASK_POLL_INTERVAL_MS, + ENHANCE_SYNC_WAIT_TIMEOUT_MS + ); + } catch (pollError) { + if (isEnhanceTaskStillProcessing(pollError)) { + return buildEnhanceTaskPendingResponse(taskId); + } + const detail = pollError instanceof Error ? pollError.message : "轮询失败"; + return NextResponse.json( + { error: "变清晰处理失败", detail }, + { status: 500 } + ); + } + + const responsePayload = await finalizeEnhanceTaskResult({ + userId: session.user.id, + taskResult, + prompt, + chatId, + messageId, + }); + + return NextResponse.json(responsePayload); + } catch (error) { + console.error("[Enhance API][TaskStatus] 处理错误:", error); + return NextResponse.json( + { error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" }, + { status: 500 } + ); + } +} + /** * 图片变清晰API路由 * 从数据库读取变清晰API配置,调用外部API进行图片增强处理 @@ -780,12 +942,21 @@ export async function POST(request: NextRequest) { ); } - // 4. 轮询获取任务结果(根据官方示例) - console.log(`[Enhance] 开始轮询任务结果,task_id: ${taskId}`); + // 4. 轮询获取任务结果(短等待,超时后让前端继续轮询) + console.log(`[Enhance] 开始短轮询任务结果,task_id: ${taskId}`); let taskResult: any; try { - taskResult = await pollTaskResult(apiUrl, config.apiKey!, taskId); + taskResult = await pollTaskResult( + apiUrl, + config.apiKey!, + taskId, + ENHANCE_TASK_POLL_INTERVAL_MS, + ENHANCE_SYNC_WAIT_TIMEOUT_MS + ); } catch (pollError) { + if (isEnhanceTaskStillProcessing(pollError)) { + return buildEnhanceTaskPendingResponse(taskId); + } const detail = pollError instanceof Error ? pollError.message : "轮询失败"; const friendlyMessage = getAosFriendlyErrorMessage(detail); return NextResponse.json( @@ -793,154 +964,17 @@ export async function POST(request: NextRequest) { { status: friendlyMessage ? 400 : 500 } ); } - - const finalProcessingTime = (Date.now() - startTime) / 1000; - - // 5. 处理返回结果 - if (taskResult.status !== 200 || !taskResult.data) { - return NextResponse.json( - { error: "变清晰处理失败", detail: taskResult.message || "未知错误" }, - { status: 500 } - ); - } - - // ✅ 记录原始输入图片大小(用于对比) - const originalImageSize = imageBuffer.length; - console.log(`[Enhance] 📊 原始输入图片大小: ${originalImageSize} bytes (${(originalImageSize / 1024 / 1024).toFixed(2)}MB)`); - - // ✅ 打印外部API返回的完整数据结构(用于调试) - console.log(`[Enhance] 📋 外部API返回的taskResult.data结构:`, { - hasImageBase64: !!taskResult.data.image_base64, - hasImage: !!taskResult.data.image, - imageType: typeof taskResult.data.image, - imagePreview: typeof taskResult.data.image === "string" ? taskResult.data.image.substring(0, 100) : "非字符串", - status: taskResult.data.status, - progress: taskResult.data.progress, - state: taskResult.data.state, - }); - - let enhancedImageBase64: string; - - if (taskResult.data.image_base64) { - // 返回的是base64 - enhancedImageBase64 = taskResult.data.image_base64; - console.log(`[Enhance] ✅ API返回了image_base64,长度: ${enhancedImageBase64.length} 字符`); - } else if (taskResult.data.image) { - // 检查是否是URL(以http://或https://开头) - const imageData = taskResult.data.image; - console.log(`[Enhance] 📥 API返回了image字段,类型: ${typeof imageData}, 值: ${typeof imageData === "string" ? imageData.substring(0, 100) : "非字符串"}`); - - if (typeof imageData === "string" && (imageData.startsWith("http://") || imageData.startsWith("https://"))) { - // 返回的是URL,需要下载 - console.log(`[Enhance] 📥 从URL下载处理后的图片: ${imageData}`); - const imageResponse = await fetch(imageData); - if (!imageResponse.ok) { - return NextResponse.json( - { error: "变清晰处理失败", detail: "无法下载处理后的图片" }, - { status: 500 } - ); - } - const downloadedBuffer = await imageResponse.arrayBuffer(); - enhancedImageBase64 = Buffer.from(downloadedBuffer).toString("base64"); - const downloadedSize = downloadedBuffer.byteLength; - console.log(`[Enhance] ✅ 从URL下载完成,Base64长度: ${enhancedImageBase64.length} 字符,文件大小: ${downloadedSize} bytes`); - - // ✅ 对比下载的图片和原图大小 - if (Math.abs(downloadedSize - originalImageSize) < 100) { - console.warn(`[Enhance] ⚠️ 警告:下载的图片大小(${downloadedSize})和原图大小(${originalImageSize})几乎相同,可能外部API返回的是原图!`); - } - } else { - // 返回的是base64字符串(直接使用) - enhancedImageBase64 = imageData; - console.log(`[Enhance] ✅ API返回了base64字符串,长度: ${enhancedImageBase64.length} 字符`); - } - } else { - console.error(`[Enhance] ❌ 轮询结果未包含图片数据,taskResult.data:`, JSON.stringify(taskResult.data, null, 2)); - return NextResponse.json( - { error: "变清晰处理失败", detail: "轮询结果未包含图片数据" }, - { status: 500 } - ); - } - if (!enhancedImageBase64) { - return NextResponse.json( - { error: "变清晰处理失败", detail: "无法获取处理后的图片" }, - { status: 500 } - ); - } - - // ✅ 验证:检查处理后的图片大小(应该和原图不同) - const enhancedBuffer = Buffer.from(enhancedImageBase64, "base64"); - const enhancedImageSize = enhancedBuffer.length; - console.log(`[Enhance] 📊 处理后的图片大小: ${enhancedImageSize} bytes (${(enhancedImageSize / 1024 / 1024).toFixed(2)}MB)`); - console.log(`[Enhance] 📊 处理后的图片Base64长度: ${enhancedImageBase64.length} 字符`); - - // ✅ 对比:如果处理后的图片大小和原图完全相同,可能是返回了原图 - if (Math.abs(enhancedImageSize - originalImageSize) < 100) { - console.warn(`[Enhance] ⚠️ 警告:处理后的图片大小(${enhancedImageSize})和原图大小(${originalImageSize})几乎相同,可能API返回的是原图!`); - } else { - const sizeDiff = ((enhancedImageSize - originalImageSize) / originalImageSize * 100).toFixed(1); - console.log(`[Enhance] ✅ 图片大小变化: ${sizeDiff}% (${sizeDiff.startsWith("-") ? "减小" : "增大"})`); - } - - // 5. 上传处理后的图片到S3存储桶 - let thumbnailUrl: string; - let originalUrl: string; - - try { - // ✅ 确认:上传的是处理后的图片(enhancedImageBase64) - console.log(`[Enhance] 📤 准备上传处理后的图片到S3,Base64长度: ${enhancedImageBase64.length}`); - const uploadResult = await uploadBase64ToS3(enhancedImageBase64, "image/jpeg"); - thumbnailUrl = uploadResult.thumbnail; - originalUrl = uploadResult.original; - console.log(`[Enhance] ✅ Images uploaded to S3 successfully`); - console.log(`[Enhance] ✅ 处理后的原图URL: ${originalUrl}`); - console.log(`[Enhance] ✅ 处理后的缩略图URL: ${thumbnailUrl}`); - } catch (uploadError) { - console.error("[Enhance] ❌ Failed to upload to S3:", uploadError); - return NextResponse.json( - { - error: "图片上传到存储桶失败", - detail: uploadError instanceof Error ? uploadError.message : "未知错误" - }, - { status: 500 } - ); - } - - // 6. 保存图片记录到数据库(相册和生图记录) - try { - const [savedImage] = await db.insert(generatedImage).values({ + const responsePayload = await finalizeEnhanceTaskResult({ userId, - chatId: chatId || null, - messageId: messageId || null, - thumbnailUrl, - originalUrl, - imageSize: "2K", // 默认尺寸 - prompt: prompt || "图片变清晰处理", - modelId: "enhance-1.0", // 标记为变清晰处理 - }).returning(); - console.log(`[Enhance] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`); - } catch (dbError) { - console.error("[Enhance] ❌ 保存到数据库失败:", dbError); - // 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问) - // 如果希望严格保证数据一致性,可以取消注释下面的代码来返回错误 - // return NextResponse.json( - // { - // error: "图片已处理但保存到相册失败", - // detail: dbError instanceof Error ? dbError.message : "未知错误" - // }, - // { status: 500 } - // ); - } + taskResult, + prompt, + chatId, + messageId, + startedAt: startTime, + }); - // 7. 返回结果(包含S3 URL和Base64,前端可以选择使用) - return NextResponse.json({ - success: true, - image_base64: enhancedImageBase64, // 保留Base64用于前端立即显示 - thumbnail_url: thumbnailUrl, // S3缩略图URL - original_url: originalUrl, // S3原图URL - processing_time: finalProcessingTime, - }); + return NextResponse.json(responsePayload); } catch (error) { console.error("[Enhance API] 处理错误:", error); return NextResponse.json( diff --git a/app/api/watermark-remove/route.ts b/app/api/watermark-remove/route.ts index 5a659b168..d0cdfa682 100644 --- a/app/api/watermark-remove/route.ts +++ b/app/api/watermark-remove/route.ts @@ -6,6 +6,8 @@ import { eq } from "drizzle-orm"; import { uploadFile } from "@/lib/storage/service"; // PicWish API 需要公网可访问的 HTTP/HTTPS URL +const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; +const WATERMARK_POLL_INTERVAL_MS = 1200; // 获取去水印API配置 async function getWatermarkRemoveConfig() { @@ -27,9 +29,13 @@ async function getWatermarkRemoveConfig() { } // 轮询获取处理结果 -async function pollForResult(taskId: string, apiKey: string): Promise<{ success: boolean; imageUrl?: string; error?: string }> { - const maxAttempts = 180; // 最多轮询180次(约3分钟) - const pollInterval = 1000; // 每次间隔1秒 +async function pollForResult( + taskId: string, + apiKey: string, + timeoutMs = WATERMARK_SYNC_WAIT_TIMEOUT_MS +): Promise<{ success: boolean; imageUrl?: string; error?: string; pending?: boolean }> { + const maxAttempts = Math.max(1, Math.ceil(timeoutMs / WATERMARK_POLL_INTERVAL_MS)); + const pollInterval = WATERMARK_POLL_INTERVAL_MS; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { @@ -81,7 +87,128 @@ async function pollForResult(taskId: string, apiKey: string): Promise<{ success: } } - return { success: false, error: "去水印任务处理超时(服务端仍在处理中),请稍后重试" }; + return { + success: false, + pending: true, + error: "去水印任务仍在处理中", + }; +} + +function buildWatermarkTaskPendingResponse(taskId: string) { + return NextResponse.json( + { + success: true, + processing: true, + status: "running", + taskId, + pollAfterMs: WATERMARK_POLL_INTERVAL_MS, + }, + { status: 202 } + ); +} + +async function finalizeWatermarkTaskResult(params: { + taskId: string; + userId: string; + startedAt?: number; + apiKey: string; +}): Promise<{ + success: boolean; + original_url: string; + thumbnail_url: string; + processing_time?: number; +}> { + const pollResult = await pollForResult(params.taskId, params.apiKey, WATERMARK_SYNC_WAIT_TIMEOUT_MS); + if (pollResult.pending) { + throw new Error("WATERMARK_TASK_PENDING"); + } + if (!pollResult.success || !pollResult.imageUrl) { + throw new Error(pollResult.error || "去水印处理失败"); + } + + const processedImageUrl = pollResult.imageUrl; + try { + const imageResponse = await fetch(processedImageUrl); + if (!imageResponse.ok) { + throw new Error("下载处理后的图片失败"); + } + const imageBuffer = await imageResponse.arrayBuffer(); + const filename = `watermark-removed-${Date.now()}.png`; + const uploadResult = await uploadFile(filename, imageBuffer); + + try { + await db.insert(generatedImage).values({ + userId: params.userId, + thumbnailUrl: uploadResult.url, + originalUrl: uploadResult.url, + imageSize: "2K", + prompt: "去水印处理", + modelId: "watermark-remove", + }); + } catch (saveError) { + console.error("[WatermarkRemove] 保存到相册失败:", saveError); + } + + return { + success: true, + original_url: uploadResult.url, + thumbnail_url: uploadResult.url, + processing_time: + typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined, + }; + } catch (uploadError) { + console.error("[WatermarkRemove] 上传结果失败,回退原始URL:", uploadError); + return { + success: true, + original_url: processedImageUrl, + thumbnail_url: processedImageUrl, + processing_time: + typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined, + }; + } +} + +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const taskId = String(searchParams.get("taskId") || "").trim(); + if (!taskId) { + return NextResponse.json({ error: "缺少 taskId 参数" }, { status: 400 }); + } + + const config = await getWatermarkRemoveConfig(); + if (!config?.apiKey) { + return NextResponse.json( + { error: "去水印服务未配置,请联系管理员在系统设置中配置API密钥" }, + { status: 500 } + ); + } + + try { + const result = await finalizeWatermarkTaskResult({ + taskId, + userId: session.user.id, + apiKey: config.apiKey, + }); + return NextResponse.json(result); + } catch (error) { + if (error instanceof Error && error.message === "WATERMARK_TASK_PENDING") { + return buildWatermarkTaskPendingResponse(taskId); + } + throw error; + } + } catch (error) { + console.error("[WatermarkRemove][TaskStatus] 处理错误:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "去水印处理失败" }, + { status: 500 } + ); + } } export async function POST(request: NextRequest) { @@ -212,66 +339,19 @@ export async function POST(request: NextRequest) { ); } - // 轮询获取结果 - const pollResult = await pollForResult(taskId, config.apiKey); - - if (!pollResult.success) { - return NextResponse.json( - { error: pollResult.error || "去水印处理失败" }, - { status: 500 } - ); - } - - // 下载处理后的图片并上传到S3 - const processedImageUrl = pollResult.imageUrl!; - console.log("[WatermarkRemove] 处理完成,结果URL:", processedImageUrl); - try { - // 下载处理后的图片 - const imageResponse = await fetch(processedImageUrl); - if (!imageResponse.ok) { - throw new Error("下载处理后的图片失败"); - } - const imageBuffer = await imageResponse.arrayBuffer(); - - // 上传到S3 - const filename = `watermark-removed-${Date.now()}.png`; - const uploadResult = await uploadFile(filename, imageBuffer); - - const processingTime = (Date.now() - startTime) / 1000; - console.log(`[WatermarkRemove] 处理完成,耗时: ${processingTime}s`); - - // 保存到相册/历史出图记录 - try { - await db.insert(generatedImage).values({ - userId: session.user.id, - thumbnailUrl: uploadResult.url, - originalUrl: uploadResult.url, - imageSize: "2K", - prompt: "去水印处理", - modelId: "watermark-remove", - }); - console.log(`[WatermarkRemove] 已保存到相册: ${uploadResult.url.substring(0, 80)}...`); - } catch (saveError) { - console.error("[WatermarkRemove] 保存到相册失败:", saveError); - } - - return NextResponse.json({ - success: true, - original_url: uploadResult.url, - thumbnail_url: uploadResult.url, - processing_time: processingTime, - }); - } catch (uploadError) { - console.error("[WatermarkRemove] 上传结果失败:", uploadError); - // 如果上传失败,直接返回原始URL - const processingTime = (Date.now() - startTime) / 1000; - return NextResponse.json({ - success: true, - original_url: processedImageUrl, - thumbnail_url: processedImageUrl, - processing_time: processingTime, + const result = await finalizeWatermarkTaskResult({ + taskId, + userId: session.user.id, + startedAt: startTime, + apiKey: config.apiKey, }); + return NextResponse.json(result); + } catch (error) { + if (error instanceof Error && error.message === "WATERMARK_TASK_PENDING") { + return buildWatermarkTaskPendingResponse(taskId); + } + throw error; } } catch (error) { console.error("[WatermarkRemove] 处理错误:", error); diff --git a/components/chat.tsx b/components/chat.tsx index bc5f9ae40..65d369531 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,6 +26,7 @@ import type { UserType } from "@/app/(auth)/auth"; import { ChatSDKError } from "@/lib/errors"; import type { Attachment, ChatMessage } from "@/lib/types"; import type { AppUsage } from "@/lib/usage"; +import { requestLongTaskResult } from "@/lib/client/long-task"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { Artifact } from "@/components/artifact"; import { useDataStream } from "@/components/data-stream-provider"; @@ -1229,25 +1230,21 @@ export function Chat({ try { const base64Data = (reader.result as string).split(",")[1]; - // 调用变清晰API - const enhanceResponse = await fetch("/api/enhance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const result = await requestLongTaskResult({ + endpoint: "/api/enhance", + payload: { image_base64: base64Data, prompt: "图片变清晰处理", - }), + chatId: id, + messageId, + }, + pollingParams: { + prompt: "图片变清晰处理", + chatId: id, + messageId, + }, }); - if (!enhanceResponse.ok) { - const error = await enhanceResponse.json(); - throw new Error(error.error || "变清晰处理失败"); - } - - const result = await enhanceResponse.json(); - if (result.success && result.original_url) { // 构造图片Markdown(使用与Gemini生成相同的格式) const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`; @@ -1510,24 +1507,13 @@ export function Chat({ }, 100); try { - // 调用去水印API - const watermarkRemoveResponse = await fetch("/api/watermark-remove", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const result = await requestLongTaskResult({ + endpoint: "/api/watermark-remove", + payload: { image_url: imageUrl, - }), + }, }); - if (!watermarkRemoveResponse.ok) { - const error = await watermarkRemoveResponse.json(); - throw new Error(error.error || "去水印处理失败"); - } - - const result = await watermarkRemoveResponse.json(); - if (result.success && result.original_url) { // 构造图片Markdown(使用与Gemini生成相同的格式) const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`; diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index f86215347..92def9bf2 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -25,6 +25,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { requestLongTaskResult } from "@/lib/client/long-task"; type Tool = "pen" | "arrow" | "circle" | "text"; @@ -742,24 +743,13 @@ export function ImageAnnotator({ try { console.log("[去水印] 使用原图URL:", originalImageUrl); - // 调用去水印API - const response = await fetch("/api/watermark-remove", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const result = await requestLongTaskResult({ + endpoint: "/api/watermark-remove", + payload: { image_url: originalImageUrl, - }), + }, }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "去水印失败"); - } - - const result = await response.json(); - if (result.success && result.original_url) { console.log("[去水印] API返回结果:", { originalUrl: result.original_url?.substring(0, 100), @@ -805,25 +795,17 @@ export function ImageAnnotator({ const base64Data = await imageUrlToBase64(originalImageUrl); console.log("[变清晰] 原图Base64长度:", base64Data.length, "字符"); - // 调用变清晰API - const response = await fetch("/api/enhance", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + const result = await requestLongTaskResult({ + endpoint: "/api/enhance", + payload: { image_base64: base64Data, - prompt: "画图标注-变清晰处理", // 可选:添加提示词 - }), + prompt: "画图标注-变清晰处理", + }, + pollingParams: { + prompt: "画图标注-变清晰处理", + }, }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "变清晰处理失败"); - } - - const result = await response.json(); - if (result.success && result.image_base64) { // 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是处理后的原图) console.log("[变清晰] API返回结果:", { diff --git a/lib/client/long-task.ts b/lib/client/long-task.ts new file mode 100644 index 000000000..32569fe02 --- /dev/null +++ b/lib/client/long-task.ts @@ -0,0 +1,91 @@ +type LongTaskResponse = { + success?: boolean; + processing?: boolean; + status?: string; + taskId?: string; + pollAfterMs?: number; + error?: string; + detail?: string; + [key: string]: any; +}; + +function isProcessingPayload(payload: LongTaskResponse | null | undefined) { + if (!payload) return false; + if (payload.processing === true) return true; + const status = String(payload.status || "").toLowerCase(); + return status === "queued" || status === "running" || status === "pending"; +} + +function buildTaskError(payload: LongTaskResponse | null | undefined, fallback: string) { + const message = payload?.error || payload?.detail || fallback; + return new Error(String(message || fallback)); +} + +async function safeParseJson(response: Response) { + try { + return (await response.json()) as LongTaskResponse; + } catch { + return null; + } +} + +export async function requestLongTaskResult(params: { + endpoint: string; + payload: Record; + pollingParams?: Record; + timeoutMs?: number; +}) { + const response = await fetch(params.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(params.payload), + }); + + const result = await safeParseJson(response); + if (!response.ok) { + throw buildTaskError(result, "请求失败"); + } + + if (!isProcessingPayload(result)) { + return result as LongTaskResponse; + } + + const taskId = String(result?.taskId || "").trim(); + if (!taskId) { + throw buildTaskError(result, "任务正在处理中,但缺少 taskId"); + } + + const pollingParams = new URLSearchParams(); + pollingParams.set("taskId", taskId); + for (const [key, value] of Object.entries(params.pollingParams || {})) { + if (value === null || value === undefined || value === "") continue; + pollingParams.set(key, String(value)); + } + + const timeoutMs = Math.max(10_000, params.timeoutMs ?? 8 * 60 * 1000); + const startedAt = Date.now(); + let pollAfterMs = Math.max(800, Number(result?.pollAfterMs || 1500)); + + while (Date.now() - startedAt < timeoutMs) { + await new Promise((resolve) => setTimeout(resolve, pollAfterMs)); + const statusResponse = await fetch(`${params.endpoint}?${pollingParams.toString()}`, { + method: "GET", + cache: "no-store", + }); + const statusPayload = await safeParseJson(statusResponse); + + if (!statusResponse.ok) { + throw buildTaskError(statusPayload, "查询任务状态失败"); + } + + if (!isProcessingPayload(statusPayload)) { + return statusPayload as LongTaskResponse; + } + + pollAfterMs = Math.max(800, Number(statusPayload?.pollAfterMs || pollAfterMs)); + } + + throw new Error("任务处理超时,请稍后再试"); +}