mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
🐛 fix: normalize image MIME from bytes (#15172)
This commit is contained in:
@@ -13,10 +13,13 @@ import {
|
||||
|
||||
// Mock the parseDataUri function since it's an implementation detail
|
||||
vi.mock('../../utils/uriParser');
|
||||
vi.mock('@lobechat/utils', () => ({
|
||||
vi.mock('@lobechat/utils', async (importOriginal) => ({
|
||||
...((await importOriginal()) as object),
|
||||
imageUrlToBase64: vi.fn(),
|
||||
}));
|
||||
|
||||
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
|
||||
|
||||
describe('anthropicHelpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -52,6 +55,30 @@ describe('anthropicHelpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should correct data URL MIME type when declared type does not match image bytes', async () => {
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
mimeType: 'image/jpeg',
|
||||
base64: PNG_BASE64,
|
||||
type: 'base64',
|
||||
});
|
||||
|
||||
const content: UserMessageContentPart = {
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:image/jpeg;base64,${PNG_BASE64}` },
|
||||
};
|
||||
|
||||
const result = await buildAnthropicBlock(content);
|
||||
|
||||
expect(result).toEqual({
|
||||
source: {
|
||||
data: PNG_BASE64,
|
||||
media_type: 'image/png',
|
||||
type: 'base64',
|
||||
},
|
||||
type: 'image',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert URL to base64 for image URLs', async () => {
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
mimeType: 'image/png',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import { imageUrlToBase64 } from '@lobechat/utils';
|
||||
import { imageUrlToBase64, resolveImageMimeTypeFromBase64 } from '@lobechat/utils';
|
||||
import type OpenAI from 'openai';
|
||||
|
||||
import type { OpenAIChatMessage, UserMessageContentPart } from '../../types';
|
||||
@@ -13,10 +13,8 @@ const ANTHROPIC_SUPPORTED_IMAGE_TYPES = new Set([
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const isImageTypeSupported = (mimeType: string | null): boolean => {
|
||||
if (!mimeType) return true;
|
||||
return ANTHROPIC_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
|
||||
};
|
||||
const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is string =>
|
||||
!!mimeType && ANTHROPIC_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
|
||||
|
||||
/**
|
||||
* Check if a text value contains visible (non-whitespace) characters.
|
||||
@@ -43,12 +41,14 @@ export const buildAnthropicBlock = async (
|
||||
const { mimeType, base64, type } = parseDataUri(content.image_url.url);
|
||||
|
||||
if (type === 'base64') {
|
||||
if (!isImageTypeSupported(mimeType)) return undefined;
|
||||
const resolvedMimeType = await resolveImageMimeTypeFromBase64(mimeType, base64);
|
||||
|
||||
if (!isImageTypeSupported(resolvedMimeType)) return undefined;
|
||||
|
||||
return {
|
||||
source: {
|
||||
data: base64 as string,
|
||||
media_type: mimeType as Anthropic.Base64ImageSource['media_type'],
|
||||
media_type: resolvedMimeType as Anthropic.Base64ImageSource['media_type'],
|
||||
type: 'base64',
|
||||
},
|
||||
type: 'image',
|
||||
|
||||
@@ -22,6 +22,8 @@ vi.mock('../../utils/imageToBase64', () => ({
|
||||
imageUrlToBase64: vi.fn(),
|
||||
}));
|
||||
|
||||
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
|
||||
|
||||
describe('google contextBuilders', () => {
|
||||
describe('GEMINI_MAGIC_THOUGHT_SIGNATURE', () => {
|
||||
it('should use skip_thought_signature_validator for Vertex AI compatibility', () => {
|
||||
@@ -82,6 +84,29 @@ describe('google contextBuilders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should correct base64 image MIME type when declared type does not match bytes', async () => {
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
base64: PNG_BASE64,
|
||||
mimeType: 'image/jpeg',
|
||||
type: 'base64',
|
||||
});
|
||||
|
||||
const content: UserMessageContentPart = {
|
||||
image_url: { url: `data:image/jpeg;base64,${PNG_BASE64}` },
|
||||
type: 'image_url',
|
||||
};
|
||||
|
||||
const result = await buildGooglePart(content);
|
||||
|
||||
expect(result).toEqual({
|
||||
inlineData: {
|
||||
data: PNG_BASE64,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle URL type images', async () => {
|
||||
const imageUrl = 'http://example.com/image.png';
|
||||
const mockBase64 = 'mockBase64Data';
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
Part,
|
||||
Tool as GoogleFunctionCallTool,
|
||||
} from '@google/genai';
|
||||
import { imageUrlToBase64 } from '@lobechat/utils';
|
||||
import { imageUrlToBase64, resolveImageMimeTypeFromBase64 } from '@lobechat/utils';
|
||||
|
||||
import type { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types';
|
||||
import { safeParseJSON } from '../../utils/safeParseJSON';
|
||||
@@ -18,10 +18,8 @@ const GOOGLE_SUPPORTED_IMAGE_TYPES = new Set([
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const isImageTypeSupported = (mimeType: string | null): boolean => {
|
||||
if (!mimeType) return true;
|
||||
return GOOGLE_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
|
||||
};
|
||||
const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is string =>
|
||||
!!mimeType && GOOGLE_SUPPORTED_IMAGE_TYPES.has(mimeType.toLowerCase());
|
||||
|
||||
/**
|
||||
* Magic thoughtSignature to bypass Gemini thought signature validation.
|
||||
@@ -58,10 +56,12 @@ export const buildGooglePart = async (
|
||||
throw new TypeError("Image URL doesn't contain base64 data");
|
||||
}
|
||||
|
||||
if (!isImageTypeSupported(mimeType)) return undefined;
|
||||
const resolvedMimeType = await resolveImageMimeTypeFromBase64(mimeType, base64);
|
||||
|
||||
if (!isImageTypeSupported(resolvedMimeType)) return undefined;
|
||||
|
||||
return {
|
||||
inlineData: { data: base64, mimeType: mimeType || 'image/png' },
|
||||
inlineData: { data: base64, mimeType: resolvedMimeType },
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"debug": "^4.4.3",
|
||||
"dompurify": "^3.3.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-type": "^21.3.0",
|
||||
"mime": "^4.1.0",
|
||||
"model-bank": "workspace:*",
|
||||
"nanoid": "^5.1.6",
|
||||
|
||||
@@ -130,24 +130,50 @@ describe('compressImageFile', () => {
|
||||
return new File([content], name, { type });
|
||||
};
|
||||
|
||||
it('should skip compression for small images', async () => {
|
||||
const file = createMockFile('small.png', 'image/png', 1000);
|
||||
|
||||
// Mock Image load with small dimensions
|
||||
const mockImageLoad = (width: number, height: number) => {
|
||||
const originalImage = global.Image;
|
||||
global.Image = class MockImage extends originalImage {
|
||||
constructor() {
|
||||
super();
|
||||
Object.defineProperty(this, 'width', { value: 800, writable: false });
|
||||
Object.defineProperty(this, 'height', { value: 600, writable: false });
|
||||
Object.defineProperty(this, 'width', { value: width, writable: false });
|
||||
Object.defineProperty(this, 'height', { value: height, writable: false });
|
||||
setTimeout(() => this.dispatchEvent(new Event('load')), 0);
|
||||
}
|
||||
} as any;
|
||||
|
||||
return () => {
|
||||
global.Image = originalImage;
|
||||
};
|
||||
};
|
||||
|
||||
it('should skip compression for small images', async () => {
|
||||
const file = createMockFile('small.png', 'image/png', 1000);
|
||||
|
||||
const restoreImage = mockImageLoad(800, 600);
|
||||
|
||||
const result = await compressImageFile(file);
|
||||
|
||||
expect(result).toBe(file); // same reference, no compression
|
||||
global.Image = originalImage;
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it('should correct MIME type for small images when declared type does not match bytes', async () => {
|
||||
const pngBytes = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
|
||||
0x15, 0xc4, 0x89,
|
||||
]);
|
||||
const file = new File([pngBytes], 'mislabelled.jpg', { type: 'image/jpeg' });
|
||||
|
||||
const restoreImage = mockImageLoad(800, 600);
|
||||
|
||||
const result = await compressImageFile(file);
|
||||
|
||||
expect(result).not.toBe(file);
|
||||
expect(result.type).toBe('image/png');
|
||||
expect(result.name).toBe('mislabelled.jpg');
|
||||
expect([...new Uint8Array(await result.arrayBuffer())]).toEqual([...pngBytes]);
|
||||
restoreImage();
|
||||
});
|
||||
|
||||
it('should compress images exceeding max dimensions', async () => {
|
||||
@@ -253,4 +279,16 @@ describe('compressImageFile', () => {
|
||||
expect(result).toBe(file);
|
||||
global.Image = originalImage;
|
||||
});
|
||||
|
||||
it('should resolve original file when MIME correction fails after image load', async () => {
|
||||
const file = createMockFile('broken-buffer.png', 'image/png', 1000);
|
||||
vi.spyOn(file, 'arrayBuffer').mockRejectedValue(new Error('Failed to read file'));
|
||||
|
||||
const restoreImage = mockImageLoad(800, 600);
|
||||
|
||||
const result = await compressImageFile(file);
|
||||
|
||||
expect(result).toBe(file);
|
||||
restoreImage();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { inferImageMimeTypeFromBytes } from './imageMimeType';
|
||||
|
||||
export const MAX_IMAGE_SIZE = 1920;
|
||||
// Anthropic enforces a 5MB cap on the base64-encoded image payload. Base64
|
||||
// inflates binary by ~4/3, so a 3MB binary file maps to ~4MB base64 — gives
|
||||
@@ -58,34 +60,52 @@ const dataUrlToFile = (dataUrl: string, name: string): File => {
|
||||
return new File([bytes], name, { type: mimeType });
|
||||
};
|
||||
|
||||
const correctImageFileType = async (file: File): Promise<File> => {
|
||||
const detectedMimeType = await inferImageMimeTypeFromBytes(await file.arrayBuffer());
|
||||
|
||||
if (!detectedMimeType || detectedMimeType === file.type) return file;
|
||||
|
||||
return new File([file], file.name, {
|
||||
lastModified: file.lastModified,
|
||||
type: detectedMimeType,
|
||||
});
|
||||
};
|
||||
|
||||
export const compressImageFile = (file: File): Promise<File> =>
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
img.addEventListener('load', async () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
// skip if image is small enough in both dimensions and file size
|
||||
if (
|
||||
img.width <= MAX_IMAGE_SIZE &&
|
||||
img.height <= MAX_IMAGE_SIZE &&
|
||||
file.size <= MAX_IMAGE_BYTES
|
||||
) {
|
||||
try {
|
||||
const normalizedFile = await correctImageFileType(file);
|
||||
const outputType = normalizedFile.type;
|
||||
|
||||
// skip if image is small enough in both dimensions and file size
|
||||
if (
|
||||
img.width <= MAX_IMAGE_SIZE &&
|
||||
img.height <= MAX_IMAGE_SIZE &&
|
||||
normalizedFile.size <= MAX_IMAGE_BYTES
|
||||
) {
|
||||
resolve(normalizedFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// progressively shrink until under 5MB
|
||||
let maxSize = MAX_IMAGE_SIZE;
|
||||
let result: File;
|
||||
do {
|
||||
const dataUrl = compressImage({ img, maxSize, type: outputType });
|
||||
result = dataUrlToFile(dataUrl, normalizedFile.name);
|
||||
maxSize = Math.round(maxSize * 0.8);
|
||||
} while (result.size > MAX_IMAGE_BYTES && maxSize > 100);
|
||||
|
||||
resolve(result);
|
||||
} catch {
|
||||
resolve(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// progressively shrink until under 5MB
|
||||
let maxSize = MAX_IMAGE_SIZE;
|
||||
let result: File;
|
||||
do {
|
||||
const dataUrl = compressImage({ img, maxSize, type: file.type });
|
||||
result = dataUrlToFile(dataUrl, file.name);
|
||||
maxSize = Math.round(maxSize * 0.8);
|
||||
} while (result.size > MAX_IMAGE_BYTES && maxSize > 100);
|
||||
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
img.addEventListener('error', () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
inferImageMimeTypeFromBase64,
|
||||
inferImageMimeTypeFromBytes,
|
||||
inferMimeTypeFromBytes,
|
||||
resolveImageMimeTypeFromBase64,
|
||||
resolveImageMimeTypeFromBytes,
|
||||
resolveMimeTypeFromBytes,
|
||||
} from './imageMimeType';
|
||||
|
||||
const PNG_BYTES = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||
0x89,
|
||||
]);
|
||||
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
|
||||
const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||
const GIF_BYTES = new TextEncoder().encode('GIF89a');
|
||||
const WEBP_BYTES = new Uint8Array([
|
||||
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
|
||||
]);
|
||||
const PDF_BYTES = new TextEncoder().encode('%PDF-1.7\n');
|
||||
|
||||
describe('imageMimeType', () => {
|
||||
it('should infer common image MIME types from bytes', async () => {
|
||||
expect(await inferImageMimeTypeFromBytes(PNG_BYTES)).toBe('image/png');
|
||||
expect(await inferImageMimeTypeFromBytes(JPEG_BYTES)).toBe('image/jpeg');
|
||||
expect(await inferImageMimeTypeFromBytes(GIF_BYTES)).toBe('image/gif');
|
||||
expect(await inferImageMimeTypeFromBytes(WEBP_BYTES)).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('should return undefined for unrecognized bytes', async () => {
|
||||
expect(await inferImageMimeTypeFromBytes(new Uint8Array([0x00, 0x01, 0x02]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should infer image MIME type from base64 data', async () => {
|
||||
expect(await inferImageMimeTypeFromBase64(PNG_BASE64)).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should infer non-image MIME types from bytes', async () => {
|
||||
expect(await inferMimeTypeFromBytes(PDF_BYTES)).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should prefer detected bytes over a wrong declared MIME type', async () => {
|
||||
expect(await resolveImageMimeTypeFromBytes('image/jpeg', PNG_BYTES)).toBe('image/png');
|
||||
expect(await resolveImageMimeTypeFromBase64('image/jpeg', PNG_BASE64)).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should fall back to declared image MIME type when bytes are not recognized', async () => {
|
||||
expect(
|
||||
await resolveImageMimeTypeFromBytes('image/jpeg; charset=utf-8', new Uint8Array([1])),
|
||||
).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should not fabricate an image MIME type when no image signal is available', async () => {
|
||||
expect(await resolveImageMimeTypeFromBase64('', 'not-valid-base64')).toBeUndefined();
|
||||
expect(await resolveImageMimeTypeFromBytes('', PDF_BYTES)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve generic MIME types without defaulting unknown bytes to image/png', async () => {
|
||||
expect(await resolveMimeTypeFromBytes('', PDF_BYTES)).toBe('application/pdf');
|
||||
expect(await resolveMimeTypeFromBytes('', new Uint8Array([1]))).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
|
||||
const normalizeMimeType = (mimeType: string | null | undefined): string => {
|
||||
return mimeType?.split(';')[0]?.trim().toLowerCase() ?? '';
|
||||
};
|
||||
|
||||
const getBytes = (input: ArrayBuffer | Uint8Array): Uint8Array =>
|
||||
input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
|
||||
const normalizeDetectedMimeType = (mimeType: string | undefined): string | undefined => {
|
||||
const normalizedMimeType = normalizeMimeType(mimeType);
|
||||
|
||||
return normalizedMimeType || undefined;
|
||||
};
|
||||
|
||||
const normalizeDetectedImageMimeType = (mimeType: string | undefined): string | undefined => {
|
||||
const normalizedMimeType = normalizeDetectedMimeType(mimeType);
|
||||
|
||||
if (!normalizedMimeType?.startsWith('image/')) return undefined;
|
||||
|
||||
return normalizedMimeType === 'image/jpg' ? 'image/jpeg' : normalizedMimeType;
|
||||
};
|
||||
|
||||
const decodeBase64Header = (base64: string): Uint8Array | undefined => {
|
||||
const header = base64.replaceAll(/\s/g, '').slice(0, 64);
|
||||
if (!header) return undefined;
|
||||
|
||||
try {
|
||||
const binary = atob(header);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const inferMimeTypeFromBytes = async (
|
||||
input: ArrayBuffer | Uint8Array,
|
||||
): Promise<string | undefined> => {
|
||||
const fileType = await fileTypeFromBuffer(getBytes(input));
|
||||
|
||||
return normalizeDetectedMimeType(fileType?.mime);
|
||||
};
|
||||
|
||||
export const inferImageMimeTypeFromBytes = async (
|
||||
input: ArrayBuffer | Uint8Array,
|
||||
): Promise<string | undefined> => {
|
||||
return normalizeDetectedImageMimeType(await inferMimeTypeFromBytes(input));
|
||||
};
|
||||
|
||||
export const inferImageMimeTypeFromBase64 = async (base64: string | null | undefined) => {
|
||||
if (!base64) return undefined;
|
||||
|
||||
const bytes = decodeBase64Header(base64);
|
||||
if (!bytes) return undefined;
|
||||
|
||||
return await inferImageMimeTypeFromBytes(bytes);
|
||||
};
|
||||
|
||||
export const resolveImageMimeTypeFromBytes = async (
|
||||
declaredMimeType: string | null | undefined,
|
||||
input: ArrayBuffer | Uint8Array,
|
||||
): Promise<string | undefined> => {
|
||||
return (
|
||||
(await inferImageMimeTypeFromBytes(input)) ??
|
||||
normalizeDetectedImageMimeType(declaredMimeType ?? undefined)
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveImageMimeTypeFromBase64 = async (
|
||||
declaredMimeType: string | null | undefined,
|
||||
base64: string | null | undefined,
|
||||
): Promise<string | undefined> => {
|
||||
return (
|
||||
(await inferImageMimeTypeFromBase64(base64)) ??
|
||||
normalizeDetectedImageMimeType(declaredMimeType ?? undefined)
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveMimeTypeFromBytes = async (
|
||||
declaredMimeType: string | null | undefined,
|
||||
input: ArrayBuffer | Uint8Array,
|
||||
): Promise<string> => {
|
||||
const declared = normalizeMimeType(declaredMimeType);
|
||||
|
||||
return (await inferMimeTypeFromBytes(input)) ?? (declared || 'application/octet-stream');
|
||||
};
|
||||
@@ -82,6 +82,34 @@ describe('imageUrlToBase64', () => {
|
||||
expect(result).toEqual({ base64: 'mockBase64String', mimeType: 'image/jpg' });
|
||||
});
|
||||
|
||||
it('should correct MIME type when response metadata does not match image bytes', async () => {
|
||||
const pngBytes = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
|
||||
0x15, 0xc4, 0x89,
|
||||
]);
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
blob: () => Promise.resolve(new Blob([pngBytes], { type: 'image/jpeg' })),
|
||||
});
|
||||
|
||||
const result = await imageUrlToBase64('https://example.com/image.jpg');
|
||||
|
||||
expect(result).toEqual({ base64: 'mockBase64String', mimeType: 'image/png' });
|
||||
});
|
||||
|
||||
it('should preserve detected non-image MIME types when response metadata is empty', async () => {
|
||||
const pdfBytes = new TextEncoder().encode('%PDF-1.7\n');
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
blob: () => Promise.resolve(new Blob([pdfBytes], { type: '' })),
|
||||
});
|
||||
|
||||
const result = await imageUrlToBase64('https://example.com/file');
|
||||
|
||||
expect(result).toEqual({ base64: 'mockBase64String', mimeType: 'application/pdf' });
|
||||
});
|
||||
|
||||
it('should throw an error when fetch fails', async () => {
|
||||
const mockError = new Error('Fetch failed');
|
||||
mockFetch.mockRejectedValue(mockError);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Buffer } from 'buffer.js';
|
||||
|
||||
import { resolveMimeTypeFromBytes } from './imageMimeType';
|
||||
|
||||
export const imageToBase64 = ({
|
||||
size,
|
||||
img,
|
||||
@@ -55,6 +57,7 @@ export const imageUrlToBase64 = async (
|
||||
|
||||
const blob = await res.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const mimeType = await resolveMimeTypeFromBytes(blob.type, arrayBuffer);
|
||||
|
||||
// Client-side uses btoa, server-side uses Buffer
|
||||
const base64 = isServer
|
||||
@@ -63,7 +66,7 @@ export const imageUrlToBase64 = async (
|
||||
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), ''),
|
||||
);
|
||||
|
||||
return { base64, mimeType: blob.type };
|
||||
return { base64, mimeType };
|
||||
} catch (error) {
|
||||
console.error('Error converting image to base64:', error);
|
||||
throw error;
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './env';
|
||||
export * from './error';
|
||||
export * from './folderStructure';
|
||||
export * from './format';
|
||||
export * from './imageMimeType';
|
||||
export * from './imageToBase64';
|
||||
export * from './jina';
|
||||
export * from './keyboard';
|
||||
|
||||
@@ -427,6 +427,59 @@ describe('FileUploadAction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should correct image file type from bytes when file.type is wrong', async () => {
|
||||
const { result } = renderHook(() => useStore());
|
||||
|
||||
const pngBytes = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
|
||||
0x15, 0xc4, 0x89,
|
||||
]);
|
||||
const mockFile = new File([pngBytes], 'mislabelled.jpg', { type: 'image/jpeg' });
|
||||
const mockMetadata = {
|
||||
date: '12345',
|
||||
dirname: '/uploads',
|
||||
filename: 'mislabelled.jpg',
|
||||
path: '/uploads/mislabelled.jpg',
|
||||
};
|
||||
const mockCheckResult = { isExist: false };
|
||||
const mockUploadResult = { data: mockMetadata, success: true };
|
||||
const mockFileResponse = {
|
||||
id: 'file-id-mislabelled',
|
||||
url: 'https://example.com/mislabelled.jpg',
|
||||
};
|
||||
const { fileTypeFromBuffer } = await import('file-type');
|
||||
|
||||
vi.spyOn(mockFile, 'arrayBuffer').mockResolvedValue(pngBytes.buffer);
|
||||
vi.mocked(fileTypeFromBuffer).mockResolvedValue({ ext: 'png', mime: 'image/png' } as any);
|
||||
vi.mocked(getImageDimensions).mockResolvedValue(undefined);
|
||||
vi.spyOn(fileService, 'checkFileHash').mockResolvedValue(mockCheckResult);
|
||||
const uploadSpy = vi
|
||||
.spyOn(uploadService, 'uploadFileToS3')
|
||||
.mockResolvedValue(mockUploadResult);
|
||||
vi.spyOn(fileService, 'createFile').mockResolvedValue(mockFileResponse);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadWithProgress({
|
||||
file: mockFile,
|
||||
});
|
||||
});
|
||||
|
||||
const uploadedFile = uploadSpy.mock.calls[0]?.[0];
|
||||
|
||||
expect(uploadedFile).toBeInstanceOf(File);
|
||||
expect(uploadedFile).not.toBe(mockFile);
|
||||
expect(uploadedFile?.name).toBe('mislabelled.jpg');
|
||||
expect(uploadedFile?.type).toBe('image/png');
|
||||
expect(fileService.createFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileType: 'image/png',
|
||||
name: 'mislabelled.jpg',
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect file type from buffer when file.type is empty', async () => {
|
||||
const { result } = renderHook(() => useStore());
|
||||
|
||||
@@ -640,7 +693,7 @@ describe('FileUploadAction', () => {
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle checkFileHash errors', async () => {
|
||||
const { result } = renderHook(() => useStore());
|
||||
const { uploadWithProgress } = useStore.getState();
|
||||
|
||||
const mockFile = new File(['test content'], 'error.png', { type: 'image/png' });
|
||||
|
||||
@@ -649,7 +702,7 @@ describe('FileUploadAction', () => {
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.uploadWithProgress({
|
||||
await uploadWithProgress({
|
||||
file: mockFile,
|
||||
});
|
||||
}),
|
||||
@@ -657,7 +710,7 @@ describe('FileUploadAction', () => {
|
||||
});
|
||||
|
||||
it('should handle uploadFileToS3 errors', async () => {
|
||||
const { result } = renderHook(() => useStore());
|
||||
const { uploadWithProgress } = useStore.getState();
|
||||
|
||||
const mockFile = new File(['test content'], 'error.png', { type: 'image/png' });
|
||||
const mockCheckResult = { isExist: false };
|
||||
@@ -668,7 +721,7 @@ describe('FileUploadAction', () => {
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.uploadWithProgress({
|
||||
await uploadWithProgress({
|
||||
file: mockFile,
|
||||
});
|
||||
}),
|
||||
@@ -676,7 +729,7 @@ describe('FileUploadAction', () => {
|
||||
});
|
||||
|
||||
it('should handle createFile errors', async () => {
|
||||
const { result } = renderHook(() => useStore());
|
||||
const { uploadWithProgress } = useStore.getState();
|
||||
|
||||
const mockFile = new File(['test content'], 'error.png', { type: 'image/png' });
|
||||
const mockMetadata = {
|
||||
@@ -695,7 +748,7 @@ describe('FileUploadAction', () => {
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.uploadWithProgress({
|
||||
await uploadWithProgress({
|
||||
file: mockFile,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LOBE_CHAT_CLOUD } from '@lobechat/business-const';
|
||||
import { inferImageMimeTypeFromBytes } from '@lobechat/utils';
|
||||
import { t } from 'i18next';
|
||||
import { sha256 } from 'js-sha256';
|
||||
|
||||
@@ -53,6 +54,20 @@ interface UploadWithProgressResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const normalizeUploadedImageFileType = async (
|
||||
file: File,
|
||||
fileArrayBuffer: ArrayBuffer,
|
||||
): Promise<File> => {
|
||||
const detectedMimeType = await inferImageMimeTypeFromBytes(fileArrayBuffer);
|
||||
|
||||
if (!detectedMimeType || detectedMimeType === file.type) return file;
|
||||
|
||||
return new File([file], file.name, {
|
||||
lastModified: file.lastModified,
|
||||
type: detectedMimeType,
|
||||
});
|
||||
};
|
||||
|
||||
type Setter = StoreSetter<FileStore>;
|
||||
export const createFileUploadSlice = (set: Setter, get: () => FileStore, _api?: unknown) =>
|
||||
new FileUploadActionImpl(set, get, _api);
|
||||
@@ -97,9 +112,10 @@ export class FileUploadActionImpl {
|
||||
|
||||
try {
|
||||
const fileArrayBuffer = await file.arrayBuffer();
|
||||
const normalizedFile = await normalizeUploadedImageFileType(file, fileArrayBuffer);
|
||||
|
||||
// 1. extract image dimensions if applicable
|
||||
const dimensions = await getImageDimensions(file);
|
||||
const dimensions = await getImageDimensions(normalizedFile);
|
||||
|
||||
// 2. check file hash
|
||||
const hash = sha256(fileArrayBuffer);
|
||||
@@ -118,14 +134,14 @@ export class FileUploadActionImpl {
|
||||
}
|
||||
// 3. if file don't exist, need upload files
|
||||
else {
|
||||
const { data, success } = await uploadService.uploadFileToS3(file, {
|
||||
const { data, success } = await uploadService.uploadFileToS3(normalizedFile, {
|
||||
abortController,
|
||||
onNotSupported: () => {
|
||||
onStatusUpdate?.({ id: statusId, type: 'removeFile' });
|
||||
message.info({
|
||||
content: t('upload.fileOnlySupportInServerMode', {
|
||||
cloud: LOBE_CHAT_CLOUD,
|
||||
ext: file.name.split('.').pop(),
|
||||
ext: normalizedFile.name.split('.').pop(),
|
||||
ns: 'error',
|
||||
}),
|
||||
duration: 5,
|
||||
@@ -146,9 +162,9 @@ export class FileUploadActionImpl {
|
||||
}
|
||||
|
||||
// 4. use more powerful file type detector to get file type
|
||||
let fileType = file.type;
|
||||
let fileType = normalizedFile.type;
|
||||
|
||||
if (!file.type) {
|
||||
if (!normalizedFile.type) {
|
||||
const { fileTypeFromBuffer } = await import('file-type');
|
||||
|
||||
const type = await fileTypeFromBuffer(fileArrayBuffer);
|
||||
@@ -161,9 +177,9 @@ export class FileUploadActionImpl {
|
||||
fileType,
|
||||
hash,
|
||||
metadata,
|
||||
name: file.name,
|
||||
name: normalizedFile.name,
|
||||
parentId,
|
||||
size: file.size,
|
||||
size: normalizedFile.size,
|
||||
source,
|
||||
url: metadata.path || checkStatus.url,
|
||||
},
|
||||
@@ -181,7 +197,7 @@ export class FileUploadActionImpl {
|
||||
},
|
||||
});
|
||||
|
||||
return { ...data, dimensions, filename: file.name };
|
||||
return { ...data, dimensions, filename: normalizedFile.name };
|
||||
} catch (error) {
|
||||
// Handle file storage plan limit error
|
||||
if ((error as any)?.message?.includes('beyond the plan limit')) {
|
||||
|
||||
Reference in New Issue
Block a user