diff --git a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx index 630959260..638f686ce 100644 --- a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx @@ -665,7 +665,7 @@ export function LocalAiImagePipelineSections({

• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等

- • 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸) + • 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)

• 返回的链接有效期为1小时,系统会自动下载并存储到S3

@@ -784,7 +784,7 @@ export function LocalAiImagePipelineSections({

• 支持2x、4x、8x放大倍数

• 处理时间:通常1-10秒,取决于图片大小

• 支持格式:JPG, PNG, JPEG等常见图片格式

-

• 文件限制:最大50MB

+

• 文件限制:最大20MB

diff --git a/app/(chat)/api/files/direct-upload/route.ts b/app/(chat)/api/files/direct-upload/route.ts index bfdb9fa1f..2bbfd6842 100644 --- a/app/(chat)/api/files/direct-upload/route.ts +++ b/app/(chat)/api/files/direct-upload/route.ts @@ -13,6 +13,10 @@ import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { upsertMediaAsset } from "@/lib/db/queries"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import { createLogger } from "@/lib/observability/logger"; import { buildS3PublicUrl, @@ -160,7 +164,10 @@ function isAllowedDirectUpload( ); } -function getPurposeMaxBytes(purpose?: string) { +function getPurposeMaxBytes(purpose?: string, contentType?: string) { + if (String(contentType || "").toLowerCase().startsWith("image/")) { + return IMAGE_UPLOAD_MAX_BYTES; + } if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) { return OPENCLAW_CHAT_VIDEO_MAX_BYTES; } @@ -281,9 +288,14 @@ export async function POST(request: Request) { { status: 400 } ); } - if (payload.size > getPurposeMaxBytes(payload.purpose)) { + const maxBytes = getPurposeMaxBytes(payload.purpose, contentType); + if (payload.size > maxBytes) { return NextResponse.json( - { error: `文件不能超过 ${getPurposeMaxBytes(payload.purpose) / 1024 / 1024}MB` }, + { + error: contentType.startsWith("image/") + ? IMAGE_UPLOAD_TOO_LARGE_MESSAGE + : `文件不能超过 ${maxBytes / 1024 / 1024}MB`, + }, { status: 413 } ); } @@ -390,8 +402,15 @@ export async function POST(request: Request) { if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) { return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 }); } - if (payload.size > getPurposeMaxBytes(payload.purpose)) { - return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 }); + if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) { + return NextResponse.json( + { + error: contentType.startsWith("image/") + ? IMAGE_UPLOAD_TOO_LARGE_MESSAGE + : "文件超过允许的上传大小", + }, + { status: 413 } + ); } const expectedPartSize = computePartSize(payload.size); if (payload.partSize !== expectedPartSize) { @@ -505,8 +524,15 @@ export async function POST(request: Request) { { status: 400 } ); } - if (payload.size > getPurposeMaxBytes(payload.purpose)) { - return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 }); + if (payload.size > getPurposeMaxBytes(payload.purpose, contentType)) { + return NextResponse.json( + { + error: contentType.startsWith("image/") + ? IMAGE_UPLOAD_TOO_LARGE_MESSAGE + : "文件超过允许的上传大小", + }, + { status: 413 } + ); } if (payload.uploadId) { diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index 26adc7335..1da734227 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, + isImageUploadTooLarge, +} from "@/lib/image-upload-limits"; import { auth } from "@/app/(auth)/auth"; import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-preview-images"; @@ -260,6 +264,12 @@ export async function POST(request: Request) { const clientUploadId = String(formData.get("clientUploadId") || "").trim() || null; const isSvgUpload = contentType === "image/svg+xml"; + if (contentType.startsWith("image/") && isImageUploadTooLarge(file.size)) { + return NextResponse.json( + { error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE }, + { status: 413 } + ); + } if ( isSvgUpload && (uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type)) diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 09f25f815..7d671d154 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -38,6 +38,10 @@ import { Settings2, } from "lucide-react"; import { toast } from "sonner"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import { Button } from "@/components/ui/button"; import { ClientErrorBoundary } from "@/components/ui/client-error-boundary"; import { @@ -305,7 +309,7 @@ const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000; const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; const MAX_AGENT_MODE_QUERY_LENGTH = 64; const MAX_AGENT_MODE_REFERENCE_COUNT = 40; -const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; +const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES; const REPLACEMENT_RERUN_MAX_REFERENCE_IMAGES = 4; const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025; const DETAIL_SCALE_TARGET_MIN_PERCENT = -80; @@ -837,7 +841,7 @@ async function uploadDetailLocalEditReferenceImage( throw new Error("请上传图片文件"); } if (file.size > DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES) { - throw new Error("参考图过大,请先压缩到 30MB 以内"); + throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); } const formData = new FormData(); diff --git a/components/agents/detail-reference-upload.ts b/components/agents/detail-reference-upload.ts index 28d9cf3ee..dd62e5ce9 100644 --- a/components/agents/detail-reference-upload.ts +++ b/components/agents/detail-reference-upload.ts @@ -1,7 +1,11 @@ import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; -const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; +const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES; export interface UploadedDetailReferenceImage { url: string; @@ -64,7 +68,7 @@ export async function uploadDetailReferenceImageFile( throw new Error("请上传图片文件"); } if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) { - throw new Error("目标详情图过大,请压缩到 50MB 以内"); + throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); } const dimensionsPromise = readLocalImageDimensions(file); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 5335e7695..82b86c089 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -13331,8 +13331,9 @@ export function CanvasChatPanel({ return null; } - if (file.size > 50 * 1024 * 1024) { - toast.error('图片大小不能超过 50MB'); + const validation = validateCanvasImageUploadFile(file); + if (!validation.ok) { + toast.error(validation.message); return null; } @@ -14509,8 +14510,9 @@ export function CanvasChatPanel({ continue; } - if (file.size > 50 * 1024 * 1024) { - toast.error('图片大小不能超过 50MB'); + const validation = validateCanvasImageUploadFile(file); + if (!validation.ok) { + toast.error(validation.message); continue; } diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 67e9d87b9..1b13cc527 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -66,6 +66,10 @@ import { type CanvasAgentPendingRequestWindow, } from "@/lib/canvas/agent-pending-requests"; import { inferOriginalImageUrl } from "@/lib/image-url"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; type ImageNodeModule = typeof import("./nodes/ImageNode"); type Model3DNodeModule = typeof import("./nodes/Model3DNode"); @@ -1569,8 +1573,8 @@ export function CanvasWorkspace({ for (let index = 0; index < files.length; index += 1) { const file = files[index]; if (!isImageFile(file)) continue; - if (file.size > 50 * 1024 * 1024) { - toast.error("图片大小不能超过 50MB"); + if (file.size > IMAGE_UPLOAD_MAX_BYTES) { + toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); continue; } diff --git a/components/canvas/chat-panel/canvas-upload-helpers.ts b/components/canvas/chat-panel/canvas-upload-helpers.ts index 392c7955e..66d4adef7 100644 --- a/components/canvas/chat-panel/canvas-upload-helpers.ts +++ b/components/canvas/chat-panel/canvas-upload-helpers.ts @@ -1,6 +1,10 @@ import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; -export const CANVAS_IMAGE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; +export const CANVAS_IMAGE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES; export type CanvasUploadFileLike = { name: string; @@ -18,7 +22,7 @@ export function validateCanvasImageUploadFile(file?: CanvasUploadFileLike | null return { ok: false, message: "请上传图片文件" }; } if (file.size > CANVAS_IMAGE_UPLOAD_MAX_BYTES) { - return { ok: false, message: "图片大小不能超过 50MB" }; + return { ok: false, message: IMAGE_UPLOAD_TOO_LARGE_MESSAGE }; } return { ok: true }; } diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index d8323cd81..d710788a4 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -46,6 +46,10 @@ import { useParams, useRouter } from "next/navigation"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import useSWR from "swr"; import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog"; import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayoutReplaceBatchDialog"; @@ -653,7 +657,7 @@ const normalizeTextEditItems = ( .filter((item): item is ImageTextEditItem => !!item); }; -const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; +const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES; const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024; const CANVAS_3D_POLL_INTERVAL_MS = 2000; const CANVAS_3D_MAX_POLL_ATTEMPTS = 360; @@ -839,7 +843,7 @@ async function uploadChatAttachmentBlob(blob: Blob, displayName: string) { } if (uploadBlob.size > CHAT_ATTACHMENT_MAX_UPLOAD_BYTES) { - throw new Error("图片过大,请先压缩后再添加到对话"); + throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); } const formData = new FormData(); @@ -6322,9 +6326,8 @@ export function ImageNode({ return; } - // 验证文件大小 (50MB) - if (file.size > 50 * 1024 * 1024) { - toast.error("图片大小不能超过 50MB"); + if (file.size > IMAGE_UPLOAD_MAX_BYTES) { + toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); return; } diff --git a/components/canvas/nodes/TextNode.tsx b/components/canvas/nodes/TextNode.tsx index ad690cf4e..50abbeea1 100644 --- a/components/canvas/nodes/TextNode.tsx +++ b/components/canvas/nodes/TextNode.tsx @@ -11,6 +11,10 @@ import { import type { CanvasNode, TextNodeData } from "@/types/canvas"; import { cn } from "@/lib/utils"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import { toast } from "sonner"; interface TextNodeProps { @@ -20,7 +24,7 @@ interface TextNodeProps { } const DEFAULT_TEXT_NODE_COLOR = "#111827"; -const PASTED_IMAGE_MAX_BYTES = 50 * 1024 * 1024; +const PASTED_IMAGE_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES; const PASTED_IMAGE_MAX_SIZE = 360; const PASTED_IMAGE_MIN_SIZE = 140; @@ -127,7 +131,7 @@ export function TextNode({ const file = files[index]; if (!file.type.startsWith("image/")) continue; if (file.size > PASTED_IMAGE_MAX_BYTES) { - toast.error("图片大小不能超过 50MB"); + toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); continue; } diff --git a/components/flow-board/drawnix-workspace.tsx b/components/flow-board/drawnix-workspace.tsx index bfc7084de..489bdbdd3 100644 --- a/components/flow-board/drawnix-workspace.tsx +++ b/components/flow-board/drawnix-workspace.tsx @@ -32,6 +32,10 @@ import { parseMindMapFileToDrawnixMind, } from "@/lib/flow-board/mind-map-import"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import { FloatingLogoPill, FloatingSidebarAction, @@ -120,7 +124,7 @@ type FlowBoardMarkerSelectionState = { const FLOW_BOARD_FILES_API = "/api/flow-board/files"; const FLOW_BOARD_DEFAULT_TEXT_COLOR = DEFAULT_COLOR; -const FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; +const FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES; const FLOW_BOARD_IMAGE_UPLOAD_TYPES = new Set([ "image/jpeg", "image/png", @@ -1114,7 +1118,7 @@ export function DrawnixWorkspace({ for (let index = 0; index < supportedFiles.length; index += 1) { const file = supportedFiles[index]; if (file.size > FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES) { - toast.error("图片大小不能超过 50MB"); + toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); continue; } diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index 747946b3a..37cea6c88 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -56,6 +56,10 @@ import { import { requestLongTaskResult } from "@/lib/client/long-task"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { buildImageProxyUrl } from "@/lib/image-delivery"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import { createClientLogger } from "@/lib/observability/client-logger"; import { cn } from "@/lib/utils"; import { Button } from "./ui/button"; @@ -323,8 +327,8 @@ export function ImageAnnotator({ if (!file.type.startsWith("image/")) { throw new Error("请上传图片文件"); } - if (file.size > 50 * 1024 * 1024) { - throw new Error("图片大小不能超过 50MB"); + if (file.size > IMAGE_UPLOAD_MAX_BYTES) { + throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); } setIsLoading(true); @@ -2008,7 +2012,7 @@ export function ImageAnnotator({ 拖拽图片到此处、点击选择或 Ctrl+V 粘贴

- 支持 JPG, PNG, WebP 格式,最大 50MB + 支持 JPG, PNG, WebP 格式,最大 20MB

=> { + const compressImage = useCallback(async (file: File, maxSizeMB = IMAGE_UPLOAD_MAX_MB): Promise => { // 如果不是图片,直接返回 if (!file.type.startsWith('image/')) { return file; @@ -739,7 +744,7 @@ function PureMultimodalInput({ const width = img.width; const height = img.height; - // ✅ 免检通道:50MB以内直接使用原图,避免分辨率与画质损失 + // 限制内直接使用原图,避免分辨率与画质损失。 if (file.size <= maxSizeMB * 1024 * 1024) { console.log(`[压缩] 免检通过: ${(file.size / 1024 / 1024).toFixed(2)}MB, ${width}x${height},使用原图`); resolve(file); @@ -848,6 +853,9 @@ function PureMultimodalInput({ uploadQueueItemId?: string ): Promise => { try { + if (file.type.startsWith("image/") && file.size > IMAGE_UPLOAD_MAX_BYTES) { + throw new Error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); + } // 先压缩图片 console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`); updateUploadQueueItem(uploadQueueItemId, { diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index f33e15432..fd79a9426 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -44,6 +44,10 @@ import { import { createCanvasPsdTemplateLaunchToken, saveCanvasPsdTemplateLaunchPayload } from "@/lib/canvas/psd-template-launch"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { requestLongTaskResult } from "@/lib/client/long-task"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import { SCENE_FUSION_ASPECT_RATIO_OPTIONS, SCENE_FUSION_IMAGE_SIZE_OPTIONS, @@ -650,8 +654,8 @@ export function SceneFusionView({ toast.error(`${file.name || "文件"}不是图片文件`); continue; } - if (file.size > 50 * 1024 * 1024) { - toast.error(`图片 ${file.name} 大小不能超过 50MB`); + if (file.size > IMAGE_UPLOAD_MAX_BYTES) { + toast.error(`${file.name}:${IMAGE_UPLOAD_TOO_LARGE_MESSAGE}`); continue; } validFiles.push(file); diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index e7ec5d540..9560cdd7a 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -1,5 +1,6 @@ import "server-only"; +import { randomUUID } from "node:crypto"; import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { createOpenAI } from "@ai-sdk/openai"; import sharp from "sharp"; @@ -20,6 +21,10 @@ import { } from "@/lib/ai/vip98k-reference-staging"; import { createLogger } from "@/lib/observability/logger"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; +import { + IMAGE_UPLOAD_MAX_BYTES, + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, +} from "@/lib/image-upload-limits"; import { buildS3PublicUrl, createS3Client, @@ -65,7 +70,7 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8; const DEFAULT_MAX_POLL_SECONDS = 600; const REFERENCE_DOWNLOAD_ATTEMPTS = 3; const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000; -const MAX_REFERENCE_DOWNLOAD_BYTES = 64 * 1024 * 1024; +const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES; const REFERENCE_STAGING_CACHE_MS = 60_000; const REFERENCE_STAGING_KEY_PREFIX = "uploads/vip98k-"; const MAX_SUBMISSION_ATTEMPTS = 3; @@ -75,6 +80,13 @@ type ReferenceStagingConfig = { storageConfig: S3UploadConfig; }; +class Vip98kReferenceTooLargeError extends Error { + constructor() { + super(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); + this.name = "Vip98kReferenceTooLargeError"; + } +} + let referenceStagingConfigCache: | { expiresAt: number; value: ReferenceStagingConfig | null } | undefined; @@ -530,12 +542,12 @@ async function downloadReferenceImage(mediaUrl: string) { Number.isFinite(declaredLength) && declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES ) { - throw new Error("source image exceeds the staging size limit"); + throw new Vip98kReferenceTooLargeError(); } const buffer = Buffer.from(await response.arrayBuffer()); if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) { - throw new Error("source image exceeds the staging size limit"); + throw new Vip98kReferenceTooLargeError(); } if ( Number.isFinite(declaredLength) && @@ -550,6 +562,7 @@ async function downloadReferenceImage(mediaUrl: string) { }; } catch (error) { lastError = error; + if (error instanceof Vip98kReferenceTooLargeError) throw error; logger.warn("reference staging download attempt failed", { attempt, sourceHost: getUrlHost(mediaUrl), @@ -589,11 +602,11 @@ async function downloadReferenceImageFromStorage( typeof response.ContentLength === "number" && response.ContentLength > MAX_REFERENCE_DOWNLOAD_BYTES ) { - throw new Error("source image exceeds the staging size limit"); + throw new Vip98kReferenceTooLargeError(); } const buffer = Buffer.from(await response.Body.transformToByteArray()); if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) { - throw new Error("source image exceeds the staging size limit"); + throw new Vip98kReferenceTooLargeError(); } if ( typeof response.ContentLength === "number" && @@ -607,6 +620,7 @@ async function downloadReferenceImageFromStorage( mimeType: response.ContentType || "image/png", }; } catch (error) { + if (error instanceof Vip98kReferenceTooLargeError) throw error; logger.warn("reference source-storage download failed; retrying public URL", { sourceHost: getUrlHost(mediaUrl), message: error instanceof Error ? error.message : String(error), @@ -811,6 +825,7 @@ async function submitTask(params: { : []; const maxAttempts = MAX_SUBMISSION_ATTEMPTS; let refreshStagedReferences = false; + let idempotencyKey = randomUUID(); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const requestBody = @@ -827,15 +842,30 @@ async function submitTask(params: { : value, ), }; - const response = await fetch(`${baseURL}${path}`, { - method: String(params.method || "POST").toUpperCase(), - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": params.contentType || "application/json", - }, - body: JSON.stringify(requestBody), - signal: AbortSignal.timeout(120_000), - }); + let response: Response; + try { + response = await fetch(`${baseURL}${path}`, { + method: String(params.method || "POST").toUpperCase(), + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": params.contentType || "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(120_000), + }); + } catch (error) { + if (attempt >= maxAttempts) throw error; + const retryDelayMs = attempt * 5_000; + logger.warn("provider submission transport failed; retrying idempotently", { + attempt, + retryDelayMs, + message: error instanceof Error ? error.message : String(error), + }); + refreshStagedReferences = false; + await delay(retryDelayMs); + continue; + } const payload = await parseJsonResponse(response); if (response.ok) { return { @@ -861,6 +891,7 @@ async function submitTask(params: { stagedReferenceCount: stagedReferences.length, }); refreshStagedReferences = true; + idempotencyKey = randomUUID(); await delay(stagedMediaRetryDelayMs); continue; } diff --git a/lib/ai/vip98k-reference-staging.ts b/lib/ai/vip98k-reference-staging.ts index 7e06d93d5..0986ba3ad 100644 --- a/lib/ai/vip98k-reference-staging.ts +++ b/lib/ai/vip98k-reference-staging.ts @@ -77,10 +77,14 @@ export function getVip98kSubmissionRetryDelay(params: { maxAttempts: number; retryAfterMs?: number | null; }) { + const recoverableSubmission = + /pending\s+safe\s+reconciliation/i.test(params.message) || + /request\s+outcome\s+(?:is\s+)?unknown/i.test(params.message) || + /final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(params.message); if ( params.status !== 503 || params.attempt >= params.maxAttempts || - !/pending\s+safe\s+reconciliation/i.test(params.message) + !recoverableSubmission ) { return null; } diff --git a/lib/client/upload-with-retry.ts b/lib/client/upload-with-retry.ts index 928de09d9..1801be91c 100644 --- a/lib/client/upload-with-retry.ts +++ b/lib/client/upload-with-retry.ts @@ -6,6 +6,10 @@ import { OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE, OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE, } from "@/lib/openclaw-chat/media-attachments"; +import { + IMAGE_UPLOAD_TOO_LARGE_MESSAGE, + isImageUploadTooLarge, +} from "@/lib/image-upload-limits"; const DEFAULT_MAX_RETRIES = 3; const MAX_BACKOFF_MS = 8000; @@ -1176,6 +1180,16 @@ export async function fetchUploadWithRetry( } ) { const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; + const file = getUploadFormFile(formData); + if ( + file?.contentType.toLowerCase().startsWith("image/") && + isImageUploadTooLarge(file.blob.size) + ) { + return createJsonResponse( + { error: IMAGE_UPLOAD_TOO_LARGE_MESSAGE }, + { status: 413 } + ); + } return withUploadSlot(async () => { const directResponse = await fetchDirectUpload( diff --git a/lib/image-upload-limits.ts b/lib/image-upload-limits.ts new file mode 100644 index 000000000..655a38424 --- /dev/null +++ b/lib/image-upload-limits.ts @@ -0,0 +1,7 @@ +export const IMAGE_UPLOAD_MAX_MB = 20; +export const IMAGE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_MB * 1024 * 1024; +export const IMAGE_UPLOAD_TOO_LARGE_MESSAGE = `图片大小不能超过 ${IMAGE_UPLOAD_MAX_MB}MB`; + +export function isImageUploadTooLarge(size: number) { + return Number.isFinite(size) && size > IMAGE_UPLOAD_MAX_BYTES; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 5e9e2acb9..3f3dc26ee 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -5681,6 +5681,7 @@ test.describe("canvas chat pure helpers", () => { }); test("validates and normalizes canvas image upload helper payloads", () => { + expect(CANVAS_IMAGE_UPLOAD_MAX_BYTES).toBe(20 * 1024 * 1024); expect(validateCanvasImageUploadFile(null)).toEqual({ ok: false, message: "请选择图片文件", @@ -5703,7 +5704,7 @@ test.describe("canvas chat pure helpers", () => { }) ).toEqual({ ok: false, - message: "图片大小不能超过 50MB", + message: "图片大小不能超过 20MB", }); expect( validateCanvasImageUploadFile({ diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 99083ad04..79ebf59e6 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -107,6 +107,15 @@ test.describe("VIP98K reference-image routing", () => { retryAfterMs: 8_000, }), ).toBe(8_000); + expect( + getVip98kSubmissionRetryDelay({ + status: 503, + message: + "The request was sent, but its final outcome could not be confirmed. Do not retry with a new idempotency key.", + attempt: 1, + maxAttempts: 3, + }), + ).toBe(15_000); expect( getVip98kSubmissionRetryDelay({ status: 503,