diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index 0a5297cb1..831484ff4 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -14,6 +14,9 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([ "image/jpg", "image/png", "image/webp", + "image/avif", + "image/tiff", + "image/x-tiff", "image/svg+xml", "image/heic", "image/heif", @@ -25,6 +28,11 @@ const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([ ]); const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]); +const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([ + "image/avif", + "image/tiff", + "image/x-tiff", +]); function normalizeUploadContentType(contentType: string, filename: string) { const normalized = contentType.trim().toLowerCase(); @@ -40,8 +48,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") { return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。"; } + if ( + SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) || + extension === "avif" || + extension === "tif" || + extension === "tiff" + ) { + return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。"; + } - return "File type should be JPEG, PNG, WebP or SVG"; + return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。"; } function isHeicUpload(contentType: string, filename: string) { @@ -53,10 +69,20 @@ function isHeicUpload(contentType: string, filename: string) { ); } +function isSharpCompatibilityUpload(contentType: string, filename: string) { + const extension = filename.toLowerCase().split(".").pop() || ""; + return ( + SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) || + extension === "avif" || + extension === "tif" || + extension === "tiff" + ); +} + function toJpegFilename(filename: string) { const trimmed = filename.trim() || `upload-${Date.now()}`; - if (/\.(heic|heif)$/i.test(trimmed)) { - return trimmed.replace(/\.(heic|heif)$/i, ".jpg"); + if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) { + return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg"); } if (/\.[a-z0-9]+$/i.test(trimmed)) { return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg"); @@ -76,6 +102,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise { } return Buffer.from(output); } + +async function convertSharpCompatibilityImageToJpeg( + buffer: Buffer +): Promise { + const sharp = (await import("sharp")).default; + return sharp(buffer, { animated: false, failOn: "none" }) + .rotate() + .flatten({ background: "#ffffff" }) + .jpeg({ + quality: 92, + mozjpeg: true, + progressive: true, + }) + .toBuffer(); +} /** * 使用Sharp智能优化图片(保持原格式) * 策略:避免双重压缩,只在需要时优化 @@ -288,6 +329,38 @@ export async function POST(request: Request) { { status: 400 } ); } + } else if (isSharpCompatibilityUpload(contentType, filename)) { + try { + const convertedBuffer = + await convertSharpCompatibilityImageToJpeg(uploadBuffer); + logger.info("[Upload API] Compatibility image converted to JPEG", { + clientUploadId, + originalContentType: contentType, + originalSize: uploadBuffer.length, + convertedSize: convertedBuffer.length, + }); + uploadBuffer = convertedBuffer; + filename = toJpegFilename(filename); + convertedFrom = contentType; + contentType = "image/jpeg"; + } catch (conversionError) { + logger.warn("[Upload API] Compatibility image conversion failed", { + clientUploadId, + contentType, + size: uploadBuffer.length, + error: + conversionError instanceof Error + ? conversionError.message + : String(conversionError), + }); + return NextResponse.json( + { + error: createUnsupportedImageTypeMessage(contentType, filename), + clientUploadId, + }, + { status: 400 } + ); + } } if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 5f8f3529d..acb17211f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -415,6 +415,19 @@ function isClawdChatMessage(message: unknown) { const CLAWD_TABLE_ACCEPT = ".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values"; const CLAWD_TABLE_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]); +const CLAWD_IMAGE_ACCEPT = + ".jpg,.jpeg,.png,.webp,.heic,.heif,.avif,.tif,.tiff,image/jpeg,image/png,image/webp,image/heic,image/heif,image/avif,image/tiff"; +const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([ + "jpg", + "jpeg", + "png", + "webp", + "heic", + "heif", + "avif", + "tif", + "tiff", +]); const CLAWD_TABLE_MAX_FILES = 3; const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024; const CLAWD_PROCESSING_HINT = @@ -444,6 +457,13 @@ function isClawdTableFile(file: File) { ); } +function isClawdImageFile(file: File) { + return ( + file.type.startsWith("image/") || + CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdTableFileExtension(file.name)) + ); +} + function normalizeClawdComposerText(value: string) { return String(value || "") .replace(/\u200b/g, " ") @@ -11906,7 +11926,7 @@ export function CanvasChatPanel({ width?: number; height?: number; } | null> => { - if (!file.type.startsWith("image/")) { + if (!isClawdImageFile(file)) { toast.error("请选择图片文件"); return null; } @@ -11936,7 +11956,15 @@ export function CanvasChatPanel({ } : undefined); if (!response.ok) { - throw new Error("上传失败"); + const payload = (await response.json().catch(() => null)) as { + error?: string; + details?: string; + } | null; + throw new Error( + payload?.error?.trim() || + payload?.details?.trim() || + "图片上传失败" + ); } const data = await response.json(); @@ -11954,7 +11982,11 @@ export function CanvasChatPanel({ assetId, }; } catch (_error) { - toast.error("图片上传失败"); + toast.error( + _error instanceof Error && _error.message + ? _error.message + : "图片上传失败" + ); return null; } }, [clearComposerUploadProgress, updateComposerUploadProgress]); @@ -12289,11 +12321,9 @@ export function CanvasChatPanel({ if (isClawdMode) { const tableFiles = selectedFiles.filter((file) => isClawdTableFile(file)); - const imageFiles = selectedFiles.filter((file) => - file.type.startsWith("image/") - ); + const imageFiles = selectedFiles.filter((file) => isClawdImageFile(file)); const unsupportedFiles = selectedFiles.filter( - (file) => !isClawdTableFile(file) && !file.type.startsWith("image/") + (file) => !isClawdTableFile(file) && !isClawdImageFile(file) ); unsupportedFiles.forEach((file) => { @@ -16095,7 +16125,7 @@ export function CanvasChatPanel({ type="file" accept={ isClawdMode - ? `image/*,${CLAWD_TABLE_ACCEPT}` + ? `${CLAWD_IMAGE_ACCEPT},${CLAWD_TABLE_ACCEPT}` : supportsAtlasVideoReference ? "image/*,video/mp4,video/quicktime,video/webm" : "image/*" diff --git a/lib/storage/service.ts b/lib/storage/service.ts index 8027d503c..7e6da8547 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -109,6 +109,11 @@ export function getContentType(filename: string): string { png: "image/png", gif: "image/gif", webp: "image/webp", + avif: "image/avif", + tif: "image/tiff", + tiff: "image/tiff", + heic: "image/heic", + heif: "image/heif", svg: "image/svg+xml", psd: "image/vnd.adobe.photoshop", psb: "application/octet-stream",