fix: stabilize vip98k image generation uploads
This commit is contained in:
parent
6c67b9323b
commit
79705c4360
@ -665,7 +665,7 @@ export function LocalAiImagePipelineSections({
|
||||
</p>
|
||||
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
|
||||
<p>
|
||||
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)
|
||||
• 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
|
||||
</p>
|
||||
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
|
||||
</div>
|
||||
@ -784,7 +784,7 @@ export function LocalAiImagePipelineSections({
|
||||
<p>• 支持2x、4x、8x放大倍数</p>
|
||||
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
|
||||
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
|
||||
<p>• 文件限制:最大50MB</p>
|
||||
<p>• 文件限制:最大20MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 粘贴
|
||||
</p>
|
||||
<p className="mb-6 text-muted-foreground/60 text-sm">
|
||||
支持 JPG, PNG, WebP 格式,最大 50MB
|
||||
支持 JPG, PNG, WebP 格式,最大 20MB
|
||||
</p>
|
||||
<Button variant="default">选择图片</Button>
|
||||
<input
|
||||
|
||||
@ -12,6 +12,11 @@ import {
|
||||
getImageCreditCostFromConfig,
|
||||
getImageModelCreditDiscountLabel,
|
||||
} from "@/lib/ai/image-model-pricing";
|
||||
import {
|
||||
IMAGE_UPLOAD_MAX_BYTES,
|
||||
IMAGE_UPLOAD_MAX_MB,
|
||||
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
|
||||
} from "@/lib/image-upload-limits";
|
||||
|
||||
// 动态模型类型定义
|
||||
type DynamicModel = {
|
||||
@ -710,7 +715,7 @@ function PureMultimodalInput({
|
||||
|
||||
// 压缩图片函数(保持原格式:JPG保持JPG,PNG保持PNG)
|
||||
// 不限制分辨率,优先保留原图质量
|
||||
const compressImage = useCallback(async (file: File, maxSizeMB = 50): Promise<File> => {
|
||||
const compressImage = useCallback(async (file: File, maxSizeMB = IMAGE_UPLOAD_MAX_MB): Promise<File> => {
|
||||
// 如果不是图片,直接返回
|
||||
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<Attachment | null> => {
|
||||
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, {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
7
lib/image-upload-limits.ts
Normal file
7
lib/image-upload-limits.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user