From 77dbe4b7b33f54af32efb30b19af9fd26abac742 Mon Sep 17 00:00:00 2001 From: sxjeru <20513115+sxjeru@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:13:54 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A8=20chore(google):=20Support=20Exter?= =?UTF-8?q?nal=20URL=20file=20input=20with=20SSRF=20validation=20to=20opti?= =?UTF-8?q?mize=20transmission=20(#12657)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: yutengjing --- locales/ar/chat.json | 2 +- locales/bg-BG/chat.json | 2 +- locales/de-DE/chat.json | 2 +- locales/en-US/chat.json | 2 +- locales/es-ES/chat.json | 2 +- locales/fa-IR/chat.json | 2 +- locales/fr-FR/chat.json | 2 +- locales/it-IT/chat.json | 2 +- locales/ja-JP/chat.json | 2 +- locales/ko-KR/chat.json | 2 +- locales/nl-NL/chat.json | 2 +- locales/pl-PL/chat.json | 2 +- locales/pt-BR/chat.json | 2 +- locales/ru-RU/chat.json | 2 +- locales/tr-TR/chat.json | 2 +- locales/vi-VN/chat.json | 2 +- locales/zh-CN/chat.json | 2 +- locales/zh-TW/chat.json | 2 +- packages/model-runtime/package.json | 1 + .../src/core/contextBuilders/google.test.ts | 205 +++++++++++++++++- .../src/core/contextBuilders/google.ts | 97 ++++++++- .../src/providers/google/index.ts | 4 +- .../model-runtime/src/utils/uriParser.test.ts | 122 ++++++++++- packages/model-runtime/src/utils/uriParser.ts | 180 +++++++++++++++ .../utils/src/client/videoValidation.test.ts | 44 ++-- packages/utils/src/client/videoValidation.ts | 4 +- .../ChatInput/ActionBar/Plus/index.tsx | 5 +- .../ChatInput/ActionBar/Upload/index.tsx | 2 + src/locales/default/chat.ts | 2 +- 29 files changed, 643 insertions(+), 59 deletions(-) diff --git a/locales/ar/chat.json b/locales/ar/chat.json index 06571ac071..2eca3f4a0d 100644 --- a/locales/ar/chat.json +++ b/locales/ar/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "جارٍ التحضير للرفع...", "upload.preview.status.processing": "جارٍ معالجة الملف...", "upload.validation.unsupportedFileType": "نوع الملف غير مدعوم: {{files}}. الصور المدعومة: JPG، PNG، GIF، WebP. المستندات المدعومة تشمل PDF، Word، Excel، PowerPoint، Markdown، النص، CSV، JSON، وملفات التعليمات البرمجية.", - "upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو 20 ميغابايت. الحجم الحالي هو {{actualSize}}.", + "upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو {{maxSize}}. الحجم الحالي هو {{actualSize}}.", "viewMode.fullWidth": "العرض الكامل", "viewMode.normal": "قياسي", "viewMode.wideScreen": "شاشة عريضة", diff --git a/locales/bg-BG/chat.json b/locales/bg-BG/chat.json index d809e2629a..6b6e0a4859 100644 --- a/locales/bg-BG/chat.json +++ b/locales/bg-BG/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Подготовка за качване...", "upload.preview.status.processing": "Обработка на файла...", "upload.validation.unsupportedFileType": "Неподдържан тип файл: {{files}}. Поддържани изображения: JPG, PNG, GIF, WebP. Поддържани документи включват PDF, Word, Excel, PowerPoint, Markdown, текст, CSV, JSON и файлове с код.", - "upload.validation.videoSizeExceeded": "Размерът на видеофайла не трябва да надвишава 20MB. Текущият размер е {{actualSize}}.", + "upload.validation.videoSizeExceeded": "Размерът на видеофайла не трябва да надвишава {{maxSize}}. Текущият размер е {{actualSize}}.", "viewMode.fullWidth": "Пълна ширина", "viewMode.normal": "Стандартен", "viewMode.wideScreen": "Широкоекранен", diff --git a/locales/de-DE/chat.json b/locales/de-DE/chat.json index d43bea7c66..64ff13dfae 100644 --- a/locales/de-DE/chat.json +++ b/locales/de-DE/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Vorbereitung zum Hochladen...", "upload.preview.status.processing": "Datei wird verarbeitet...", "upload.validation.unsupportedFileType": "Nicht unterstützter Dateityp: {{files}}. Unterstützte Bilder: JPG, PNG, GIF, WebP. Unterstützte Dokumente umfassen PDF, Word, Excel, PowerPoint, Markdown, Text, CSV, JSON und Code-Dateien.", - "upload.validation.videoSizeExceeded": "Die Videodatei darf 20 MB nicht überschreiten. Aktuelle Größe: {{actualSize}}.", + "upload.validation.videoSizeExceeded": "Die Videodatei darf {{maxSize}} nicht überschreiten. Aktuelle Größe: {{actualSize}}.", "viewMode.fullWidth": "Volle Breite", "viewMode.normal": "Standard", "viewMode.wideScreen": "Breitbild", diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index cafbeb85f6..46b311bb3d 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -865,7 +865,7 @@ "upload.preview.status.pending": "Preparing to upload...", "upload.preview.status.processing": "Processing file...", "upload.validation.unsupportedFileType": "Unsupported file type: {{files}}. Supported images: JPG, PNG, GIF, WebP. Supported documents include PDF, Word, Excel, PowerPoint, Markdown, text, CSV, JSON, and code files.", - "upload.validation.videoSizeExceeded": "Video file size must not exceed 20MB. Current file size is {{actualSize}}.", + "upload.validation.videoSizeExceeded": "Video file size must not exceed {{maxSize}}. Current file size is {{actualSize}}.", "viewMode.fullWidth": "Full Width", "viewMode.normal": "Standard", "viewMode.wideScreen": "Widescreen", diff --git a/locales/es-ES/chat.json b/locales/es-ES/chat.json index 0800cbda1f..acb79147db 100644 --- a/locales/es-ES/chat.json +++ b/locales/es-ES/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Preparando para subir...", "upload.preview.status.processing": "Procesando archivo...", "upload.validation.unsupportedFileType": "Tipo de archivo no compatible: {{files}}. Imágenes compatibles: JPG, PNG, GIF, WebP. Documentos compatibles incluyen PDF, Word, Excel, PowerPoint, Markdown, texto, CSV, JSON y archivos de código.", - "upload.validation.videoSizeExceeded": "El tamaño del archivo de video no debe superar los 20MB. Tamaño actual: {{actualSize}}.", + "upload.validation.videoSizeExceeded": "El tamaño del archivo de video no debe superar los {{maxSize}}. Tamaño actual: {{actualSize}}.", "viewMode.fullWidth": "Ancho completo", "viewMode.normal": "Estándar", "viewMode.wideScreen": "Pantalla ancha", diff --git a/locales/fa-IR/chat.json b/locales/fa-IR/chat.json index f74290e9eb..d1a0290e9e 100644 --- a/locales/fa-IR/chat.json +++ b/locales/fa-IR/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "در حال آماده‌سازی برای بارگذاری...", "upload.preview.status.processing": "در حال پردازش فایل...", "upload.validation.unsupportedFileType": "نوع فایل پشتیبانی نشده: {{files}}. تصاویر پشتیبانی شده: JPG، PNG، GIF، WebP. اسناد پشتیبانی شده شامل PDF، Word، Excel، PowerPoint، Markdown، متن، CSV، JSON و فایل‌های کد هستند.", - "upload.validation.videoSizeExceeded": "حجم فایل ویدیو نباید بیش از ۲۰ مگابایت باشد. حجم فعلی: {{actualSize}}.", + "upload.validation.videoSizeExceeded": "حجم فایل ویدیو نباید بیش از {{maxSize}} باشد. حجم فعلی: {{actualSize}}.", "viewMode.fullWidth": "تمام‌عرض", "viewMode.normal": "استاندارد", "viewMode.wideScreen": "نمای عریض", diff --git a/locales/fr-FR/chat.json b/locales/fr-FR/chat.json index 209bbbef31..46cac2c55f 100644 --- a/locales/fr-FR/chat.json +++ b/locales/fr-FR/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Préparation au téléversement...", "upload.preview.status.processing": "Traitement du fichier...", "upload.validation.unsupportedFileType": "Type de fichier non pris en charge : {{files}}. Images prises en charge : JPG, PNG, GIF, WebP. Les documents pris en charge incluent PDF, Word, Excel, PowerPoint, Markdown, texte, CSV, JSON et fichiers de code.", - "upload.validation.videoSizeExceeded": "La taille du fichier vidéo ne doit pas dépasser 20 Mo. Taille actuelle : {{actualSize}}.", + "upload.validation.videoSizeExceeded": "La taille du fichier vidéo ne doit pas dépasser {{maxSize}}. Taille actuelle : {{actualSize}}.", "viewMode.fullWidth": "Pleine largeur", "viewMode.normal": "Standard", "viewMode.wideScreen": "Grand écran", diff --git a/locales/it-IT/chat.json b/locales/it-IT/chat.json index b4dbb6221e..c1692dfba3 100644 --- a/locales/it-IT/chat.json +++ b/locales/it-IT/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Preparazione al caricamento...", "upload.preview.status.processing": "Elaborazione file...", "upload.validation.unsupportedFileType": "Tipo di file non supportato: {{files}}. Immagini supportate: JPG, PNG, GIF, WebP. Documenti supportati includono PDF, Word, Excel, PowerPoint, Markdown, testo, CSV, JSON e file di codice.", - "upload.validation.videoSizeExceeded": "La dimensione del file video non deve superare i 20MB. Dimensione attuale: {{actualSize}}.", + "upload.validation.videoSizeExceeded": "La dimensione del file video non deve superare i {{maxSize}}. Dimensione attuale: {{actualSize}}.", "viewMode.fullWidth": "Larghezza completa", "viewMode.normal": "Standard", "viewMode.wideScreen": "Widescreen", diff --git a/locales/ja-JP/chat.json b/locales/ja-JP/chat.json index 5eebed81e9..b34b31929b 100644 --- a/locales/ja-JP/chat.json +++ b/locales/ja-JP/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "アップロードの準備中…", "upload.preview.status.processing": "ファイル処理中…", "upload.validation.unsupportedFileType": "サポートされていないファイルタイプ: {{files}}。サポートされている画像形式: JPG、PNG、GIF、WebP。サポートされているドキュメント形式: PDF、Word、Excel、PowerPoint、Markdown、テキスト、CSV、JSON、コードファイル。", - "upload.validation.videoSizeExceeded": "ビデオファイルは 20MB を超えることはできません。現在は {{actualSize}} です", + "upload.validation.videoSizeExceeded": "ビデオファイルは {{maxSize}} を超えることはできません。現在は {{actualSize}} です", "viewMode.fullWidth": "全幅表示", "viewMode.normal": "標準", "viewMode.wideScreen": "ワイドスクリーン", diff --git a/locales/ko-KR/chat.json b/locales/ko-KR/chat.json index e3200fb98e..a694fdcb65 100644 --- a/locales/ko-KR/chat.json +++ b/locales/ko-KR/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "업로드 준비 중…", "upload.preview.status.processing": "파일 처리 중…", "upload.validation.unsupportedFileType": "지원되지 않는 파일 형식: {{files}}. 지원되는 이미지: JPG, PNG, GIF, WebP. 지원되는 문서: PDF, Word, Excel, PowerPoint, Markdown, 텍스트, CSV, JSON 및 코드 파일.", - "upload.validation.videoSizeExceeded": "비디오 파일은 20MB를 초과할 수 없습니다. 현재 {{actualSize}}입니다", + "upload.validation.videoSizeExceeded": "비디오 파일은 {{maxSize}}를 초과할 수 없습니다. 현재 {{actualSize}}입니다", "viewMode.fullWidth": "전체 너비", "viewMode.normal": "일반", "viewMode.wideScreen": "와이드스크린", diff --git a/locales/nl-NL/chat.json b/locales/nl-NL/chat.json index 2adde22ba5..666b56f7e9 100644 --- a/locales/nl-NL/chat.json +++ b/locales/nl-NL/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Voorbereiden op upload...", "upload.preview.status.processing": "Bestand verwerken...", "upload.validation.unsupportedFileType": "Niet-ondersteund bestandstype: {{files}}. Ondersteunde afbeeldingen: JPG, PNG, GIF, WebP. Ondersteunde documenten zijn onder andere PDF, Word, Excel, PowerPoint, Markdown, tekst, CSV, JSON en codebestanden.", - "upload.validation.videoSizeExceeded": "De bestandsgrootte van de video mag niet groter zijn dan 20MB. Huidige grootte is {{actualSize}}.", + "upload.validation.videoSizeExceeded": "De bestandsgrootte van de video mag niet groter zijn dan {{maxSize}}. Huidige grootte is {{actualSize}}.", "viewMode.fullWidth": "Volledige breedte", "viewMode.normal": "Standaard", "viewMode.wideScreen": "Widescreen", diff --git a/locales/pl-PL/chat.json b/locales/pl-PL/chat.json index cc78799597..c4b1c2457a 100644 --- a/locales/pl-PL/chat.json +++ b/locales/pl-PL/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Przygotowywanie do przesłania...", "upload.preview.status.processing": "Przetwarzanie pliku...", "upload.validation.unsupportedFileType": "Nieobsługiwany typ pliku: {{files}}. Obsługiwane obrazy: JPG, PNG, GIF, WebP. Obsługiwane dokumenty obejmują PDF, Word, Excel, PowerPoint, Markdown, tekst, CSV, JSON i pliki kodu.", - "upload.validation.videoSizeExceeded": "Rozmiar pliku wideo nie może przekraczać 20 MB. Obecny rozmiar pliku to {{actualSize}}.", + "upload.validation.videoSizeExceeded": "Rozmiar pliku wideo nie może przekraczać {{maxSize}}. Obecny rozmiar pliku to {{actualSize}}.", "viewMode.fullWidth": "Pełna szerokość", "viewMode.normal": "Standardowy", "viewMode.wideScreen": "Szeroki ekran", diff --git a/locales/pt-BR/chat.json b/locales/pt-BR/chat.json index a750ab4add..5e19d8163b 100644 --- a/locales/pt-BR/chat.json +++ b/locales/pt-BR/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Preparando para envio...", "upload.preview.status.processing": "Processando arquivo...", "upload.validation.unsupportedFileType": "Tipo de arquivo não suportado: {{files}}. Imagens suportadas: JPG, PNG, GIF, WebP. Documentos suportados incluem PDF, Word, Excel, PowerPoint, Markdown, texto, CSV, JSON e arquivos de código.", - "upload.validation.videoSizeExceeded": "O tamanho do vídeo não deve exceder 20MB. Tamanho atual: {{actualSize}}.", + "upload.validation.videoSizeExceeded": "O tamanho do vídeo não deve exceder {{maxSize}}. Tamanho atual: {{actualSize}}.", "viewMode.fullWidth": "Largura Total", "viewMode.normal": "Padrão", "viewMode.wideScreen": "Tela Larga", diff --git a/locales/ru-RU/chat.json b/locales/ru-RU/chat.json index aacd1c40f0..a8adce56f7 100644 --- a/locales/ru-RU/chat.json +++ b/locales/ru-RU/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Подготовка к загрузке...", "upload.preview.status.processing": "Обработка файла...", "upload.validation.unsupportedFileType": "Неподдерживаемый тип файла: {{files}}. Поддерживаемые изображения: JPG, PNG, GIF, WebP. Поддерживаемые документы: PDF, Word, Excel, PowerPoint, Markdown, текст, CSV, JSON и файлы кода.", - "upload.validation.videoSizeExceeded": "Размер видеофайла не должен превышать 20 МБ. Текущий размер: {{actualSize}}.", + "upload.validation.videoSizeExceeded": "Размер видеофайла не должен превышать {{maxSize}}. Текущий размер: {{actualSize}}.", "viewMode.fullWidth": "Полная ширина", "viewMode.normal": "Стандартный", "viewMode.wideScreen": "Широкий экран", diff --git a/locales/tr-TR/chat.json b/locales/tr-TR/chat.json index cdd5d6427d..9d1923ab32 100644 --- a/locales/tr-TR/chat.json +++ b/locales/tr-TR/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Yüklemeye hazırlanıyor...", "upload.preview.status.processing": "Dosya işleniyor...", "upload.validation.unsupportedFileType": "Desteklenmeyen dosya türü: {{files}}. Desteklenen görseller: JPG, PNG, GIF, WebP. Desteklenen belgeler: PDF, Word, Excel, PowerPoint, Markdown, metin, CSV, JSON ve kod dosyaları.", - "upload.validation.videoSizeExceeded": "Video dosya boyutu 20MB'ı geçmemelidir. Mevcut dosya boyutu {{actualSize}}.", + "upload.validation.videoSizeExceeded": "Video dosya boyutu {{maxSize}}'ı geçmemelidir. Mevcut dosya boyutu {{actualSize}}.", "viewMode.fullWidth": "Tam Genişlik", "viewMode.normal": "Standart", "viewMode.wideScreen": "Geniş Ekran", diff --git a/locales/vi-VN/chat.json b/locales/vi-VN/chat.json index 49b5aad4b2..9a424959df 100644 --- a/locales/vi-VN/chat.json +++ b/locales/vi-VN/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "Đang chuẩn bị tải lên...", "upload.preview.status.processing": "Đang xử lý tệp...", "upload.validation.unsupportedFileType": "Loại tệp không được hỗ trợ: {{files}}. Hình ảnh được hỗ trợ: JPG, PNG, GIF, WebP. Các tài liệu được hỗ trợ bao gồm PDF, Word, Excel, PowerPoint, Markdown, văn bản, CSV, JSON và tệp mã.", - "upload.validation.videoSizeExceeded": "Kích thước tệp video không được vượt quá 20MB. Kích thước hiện tại là {{actualSize}}.", + "upload.validation.videoSizeExceeded": "Kích thước tệp video không được vượt quá {{maxSize}}. Kích thước hiện tại là {{actualSize}}.", "viewMode.fullWidth": "Toàn chiều rộng", "viewMode.normal": "Chuẩn", "viewMode.wideScreen": "Toàn màn hình", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 799c20d2c9..77bf63942f 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -865,7 +865,7 @@ "upload.preview.status.pending": "准备上传…", "upload.preview.status.processing": "文件处理中…", "upload.validation.unsupportedFileType": "不支持的文件类型:{{files}}。支持的图片格式:JPG、PNG、GIF、WebP。支持的文档包括PDF、Word、Excel、PowerPoint、Markdown、文本、CSV、JSON和代码文件。", - "upload.validation.videoSizeExceeded": "视频文件不能超过 20MB。当前为 {{actualSize}}", + "upload.validation.videoSizeExceeded": "视频文件不能超过 {{maxSize}}。当前为 {{actualSize}}", "viewMode.fullWidth": "全宽显示", "viewMode.normal": "普通", "viewMode.wideScreen": "宽屏", diff --git a/locales/zh-TW/chat.json b/locales/zh-TW/chat.json index 3d3f946581..7172d84ac1 100644 --- a/locales/zh-TW/chat.json +++ b/locales/zh-TW/chat.json @@ -846,7 +846,7 @@ "upload.preview.status.pending": "準備上傳...", "upload.preview.status.processing": "檔案處理中...", "upload.validation.unsupportedFileType": "不支援的檔案類型:{{files}}。支援的圖片格式:JPG、PNG、GIF、WebP。支援的文件包括 PDF、Word、Excel、PowerPoint、Markdown、文字檔、CSV、JSON 和程式碼檔案。", - "upload.validation.videoSizeExceeded": "影片檔案大小不能超過 20MB,當前檔案大小為 {{actualSize}}", + "upload.validation.videoSizeExceeded": "影片檔案大小不能超過 {{maxSize}},當前檔案大小為 {{actualSize}}", "viewMode.fullWidth": "全寬顯示", "viewMode.normal": "一般", "viewMode.wideScreen": "寬螢幕", diff --git a/packages/model-runtime/package.json b/packages/model-runtime/package.json index 97cd56a3d8..9a73de4e2b 100644 --- a/packages/model-runtime/package.json +++ b/packages/model-runtime/package.json @@ -22,6 +22,7 @@ "@lobechat/business-model-bank": "workspace:*", "@lobechat/business-model-runtime": "workspace:*", "@lobechat/const": "workspace:*", + "@lobechat/ssrf-safe-fetch": "workspace:*", "@lobechat/utils": "workspace:*", "async-retry": "^1.3.3", "dayjs": "^1.11.19", diff --git a/packages/model-runtime/src/core/contextBuilders/google.test.ts b/packages/model-runtime/src/core/contextBuilders/google.test.ts index 75fcaab05e..58a2499c14 100644 --- a/packages/model-runtime/src/core/contextBuilders/google.test.ts +++ b/packages/model-runtime/src/core/contextBuilders/google.test.ts @@ -1,9 +1,9 @@ // @vitest-environment node import * as imageToBase64Module from '@lobechat/utils'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types'; -import { parseDataUri } from '../../utils/uriParser'; +import { isPublicExternalUrl, parseDataUri, validateExternalUrl } from '../../utils/uriParser'; import { buildGoogleMessage, buildGoogleMessages, @@ -15,7 +15,9 @@ import { // Mock the utils vi.mock('../../utils/uriParser', () => ({ + isPublicExternalUrl: vi.fn().mockReturnValue(false), parseDataUri: vi.fn(), + validateExternalUrl: vi.fn().mockResolvedValue({ isValid: false, reason: 'mocked' }), })); vi.mock('../../utils/imageToBase64', () => ({ @@ -34,6 +36,10 @@ describe('google contextBuilders', () => { }); describe('buildGooglePart', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('should handle text type messages', async () => { const content: UserMessageContentPart = { text: 'Hello', @@ -140,6 +146,161 @@ describe('google contextBuilders', () => { expect(imageToBase64Module.imageUrlToBase64).toHaveBeenCalledWith(imageUrl); }); + it('should use fileData for external URL images on gemini-3+', async () => { + const imageUrl = 'https://example.com/image.png'; + + vi.mocked(parseDataUri).mockReturnValueOnce({ + base64: null, + mimeType: null, + type: 'url', + }); + + vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true); + vi.mocked(validateExternalUrl).mockResolvedValueOnce({ + contentLength: 1024, + contentType: 'image/png', + isValid: true, + }); + + const imageToBase64Spy = vi + .spyOn(imageToBase64Module, 'imageUrlToBase64') + .mockResolvedValueOnce({ + base64: 'mockBase64Data', + mimeType: 'image/png', + }); + + const content: UserMessageContentPart = { + image_url: { url: imageUrl }, + type: 'image_url', + }; + + const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' }); + + expect(result).toEqual({ + fileData: { + fileUri: imageUrl, + mimeType: 'image/png', + }, + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, + }); + + expect(imageToBase64Spy).not.toHaveBeenCalled(); + }); + + it('should fallback to inlineData when external URL validation fails for HEIC', async () => { + const imageUrl = 'https://example.com/image.heic'; + + vi.mocked(parseDataUri).mockReturnValueOnce({ + base64: null, + mimeType: null, + type: 'url', + }); + + vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true); + vi.mocked(validateExternalUrl).mockResolvedValueOnce({ + contentLength: 1024, + contentType: 'image/heic', + isValid: false, + reason: 'Unsupported content type: image/heic', + }); + + const imageToBase64Spy = vi + .spyOn(imageToBase64Module, 'imageUrlToBase64') + .mockResolvedValueOnce({ + base64: 'mockBase64Data', + mimeType: 'image/heic', + }); + + const content: UserMessageContentPart = { + image_url: { url: imageUrl }, + type: 'image_url', + }; + + const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' }); + + expect(result).toEqual({ + inlineData: { + data: 'mockBase64Data', + mimeType: 'image/heic', + }, + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, + }); + + expect(imageToBase64Spy).toHaveBeenCalledWith(imageUrl); + }); + + it('should force inlineData for external URL images on gemini-2.5 and earlier', async () => { + const imageUrl = 'https://example.com/image.png'; + + vi.mocked(parseDataUri).mockReturnValueOnce({ + base64: null, + mimeType: null, + type: 'url', + }); + + const imageToBase64Spy = vi + .spyOn(imageToBase64Module, 'imageUrlToBase64') + .mockResolvedValueOnce({ + base64: 'mockBase64Data', + mimeType: 'image/png', + }); + + const content: UserMessageContentPart = { + image_url: { url: imageUrl }, + type: 'image_url', + }; + + const result = await buildGooglePart(content, { model: 'gemini-2.5-flash' }); + + expect(result).toEqual({ + inlineData: { + data: 'mockBase64Data', + mimeType: 'image/png', + }, + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, + }); + + expect(imageToBase64Spy).toHaveBeenCalledWith(imageUrl); + expect(isPublicExternalUrl).not.toHaveBeenCalled(); + expect(validateExternalUrl).not.toHaveBeenCalled(); + }); + + it('should throw when external URL exceeds size limit', async () => { + const imageUrl = 'https://example.com/large-image.png'; + + vi.mocked(parseDataUri).mockReturnValueOnce({ + base64: null, + mimeType: 'image/png', + type: 'url', + }); + + vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true); + vi.mocked(validateExternalUrl).mockResolvedValueOnce({ + contentLength: 120 * 1024 * 1024, + contentType: 'image/png', + isTooLarge: true, + isValid: false, + reason: 'File too large: 120MB', + }); + + const imageToBase64Spy = vi + .spyOn(imageToBase64Module, 'imageUrlToBase64') + .mockResolvedValueOnce({ + base64: 'mockBase64Data', + mimeType: 'image/png', + }); + + const content: UserMessageContentPart = { + image_url: { url: imageUrl }, + type: 'image_url', + }; + + await expect(buildGooglePart(content, { model: 'gemini-3-flash' })).rejects.toThrow( + RangeError, + ); + expect(imageToBase64Spy).not.toHaveBeenCalled(); + }); + it('should throw TypeError for unsupported image URL types', async () => { const unsupportedImageUrl = 'unsupported://example.com/image.png'; @@ -182,6 +343,46 @@ describe('google contextBuilders', () => { }); }); + it('should use fileData for external URL videos on gemini-3+', async () => { + const videoUrl = 'https://example.com/video.mp4'; + + vi.mocked(parseDataUri).mockReturnValueOnce({ + base64: null, + mimeType: null, + type: 'url', + }); + + vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true); + vi.mocked(validateExternalUrl).mockResolvedValueOnce({ + contentLength: 1024, + contentType: 'video/mp4', + isValid: true, + }); + + const imageToBase64Spy = vi + .spyOn(imageToBase64Module, 'imageUrlToBase64') + .mockResolvedValueOnce({ + base64: 'mockVideoBase64Data', + mimeType: 'video/mp4', + }); + + const content: UserMessageContentPart = { + type: 'video_url', + video_url: { url: videoUrl }, + }; + + const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' }); + + expect(result).toEqual({ + fileData: { + fileUri: videoUrl, + mimeType: 'video/mp4', + }, + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, + }); + expect(imageToBase64Spy).not.toHaveBeenCalled(); + }); + it('should return undefined for unsupported SVG image (base64)', async () => { const svgBase64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg=='; diff --git a/packages/model-runtime/src/core/contextBuilders/google.ts b/packages/model-runtime/src/core/contextBuilders/google.ts index 2befb7cc8d..a8d87f8c89 100644 --- a/packages/model-runtime/src/core/contextBuilders/google.ts +++ b/packages/model-runtime/src/core/contextBuilders/google.ts @@ -8,14 +8,15 @@ import { imageUrlToBase64, resolveImageMimeTypeFromBase64 } from '@lobechat/util import type { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types'; import { safeParseJSON } from '../../utils/safeParseJSON'; -import { parseDataUri } from '../../utils/uriParser'; +import { isPublicExternalUrl, parseDataUri, validateExternalUrl } from '../../utils/uriParser'; const GOOGLE_SUPPORTED_IMAGE_TYPES = new Set([ - 'image/jpeg', - 'image/jpg', 'image/png', - 'image/gif', + 'image/jpeg', + 'image/jpg', // non-standard but widely used alias for image/jpeg 'image/webp', + 'image/heic', + 'image/heif', ]); const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is string => @@ -30,11 +31,66 @@ const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is */ export const GEMINI_MAGIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator'; +const getGeminiMajorVersion = (model?: string) => { + if (!model) return null; + + // Examples: + // - gemini-3-flash-preview + // - gemini-2.5-flash + const match = model.match(/gemini-(\d+)(?:\.(\d+))?/i); + if (!match?.[1]) return null; + + const major = Number.parseInt(match[1], 10); + return Number.isFinite(major) ? major : null; +}; + +/** + * External HTTP / Signed URLs support varies by model generation. + * In practice, Gemini 3+ supports `fileData.fileUri` for external URLs reliably, + * while earlier models often require `inlineData`. + * Returns false for unversioned model IDs (e.g. gemini-pro) to avoid request failures. + */ +const supportsExternalUrlFileData = (model?: string) => { + const major = getGeminiMajorVersion(model); + if (major === null) return false; + return major >= 3; +}; + +const buildExternalUrlFileDataPart = async ( + url: string, + options?: { model?: string }, +): Promise => { + if (!supportsExternalUrlFileData(options?.model) || !isPublicExternalUrl(url)) return undefined; + + const validation = await validateExternalUrl(url); + if (validation.isValid) { + return { + fileData: { + fileUri: url, + mimeType: validation.contentType, + }, + thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, + }; + } + + if (validation.isTooLarge) { + throw new RangeError(validation.reason || 'External URL file too large'); + } + + return undefined; +}; + /** * Convert OpenAI content part to Google Part format + * + * TODO: urlContext tool only supports files up to 34MB. In the future, we should + * detect file URLs in the conversation and use External URL feature (fileData.fileUri) + * for files larger than 34MB to avoid urlContext limitations. + * @see https://ai.google.dev/gemini-api/docs/file-input-methods */ export const buildGooglePart = async ( content: UserMessageContentPart, + options?: { model?: string }, ): Promise => { switch (content.type) { default: { @@ -67,12 +123,19 @@ export const buildGooglePart = async ( } if (type === 'url') { - const { base64, mimeType } = await imageUrlToBase64(content.image_url.url); + const url = content.image_url.url; - if (!isImageTypeSupported(mimeType)) return undefined; + const externalUrlPart = await buildExternalUrlFileDataPart(url, options); + if (externalUrlPart) return externalUrlPart; + + // Fallback: convert URL to base64 (for private/local URLs or failed validation) + const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(url); + const resolvedMimeType = urlMimeType || mimeType; + + if (!isImageTypeSupported(resolvedMimeType)) return undefined; return { - inlineData: { data: base64, mimeType }, + inlineData: { data: urlBase64, mimeType: resolvedMimeType || 'image/png' }, thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, }; } @@ -95,12 +158,18 @@ export const buildGooglePart = async ( } if (type === 'url') { + const url = content.video_url.url; + + const externalUrlPart = await buildExternalUrlFileDataPart(url, options); + if (externalUrlPart) return externalUrlPart; + + // Fallback: convert URL to base64 // Use imageUrlToBase64 for SSRF protection (works for any binary data including videos) // Note: This might need size/duration limits for practical use - const { base64, mimeType } = await imageUrlToBase64(content.video_url.url); + const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(url); return { - inlineData: { data: base64, mimeType }, + inlineData: { data: urlBase64, mimeType: urlMimeType }, thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE, }; } @@ -116,6 +185,7 @@ export const buildGooglePart = async ( export const buildGoogleMessage = async ( message: OpenAIChatMessage, toolCallNameMap?: Map, + options?: { model?: string }, ): Promise => { const content = message.content as string | UserMessageContentPart[]; @@ -191,7 +261,7 @@ export const buildGoogleMessage = async ( if (typeof content === 'string') return [{ text: content, thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE }]; - const parts = await Promise.all(content.map(async (c) => await buildGooglePart(c))); + const parts = await Promise.all(content.map(async (c) => await buildGooglePart(c, options))); return parts.filter(Boolean) as Part[]; }; @@ -204,7 +274,10 @@ export const buildGoogleMessage = async ( /** * Convert messages from the OpenAI format to Google GenAI SDK format */ -export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promise => { +export const buildGoogleMessages = async ( + messages: OpenAIChatMessage[], + options?: { model?: string }, +): Promise => { const toolCallNameMap = new Map(); // Build tool call id to name mapping @@ -220,7 +293,7 @@ export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promis const pools = messages .filter((message) => message.role !== 'function') - .map(async (msg) => await buildGoogleMessage(msg, toolCallNameMap)); + .map(async (msg) => await buildGoogleMessage(msg, toolCallNameMap, options)); const contents = await Promise.all(pools); diff --git a/packages/model-runtime/src/providers/google/index.ts b/packages/model-runtime/src/providers/google/index.ts index 3bf17c17b4..a23da159e8 100644 --- a/packages/model-runtime/src/providers/google/index.ts +++ b/packages/model-runtime/src/providers/google/index.ts @@ -186,7 +186,7 @@ export class LobeGoogleAI implements LobeRuntimeAI { thinkingLevel, }) as ThinkingConfig; - const contents = await buildGoogleMessages(payload.messages); + const contents = await buildGoogleMessages(payload.messages, { model }); const controller = new AbortController(); const originalSignal = options?.signal; @@ -328,7 +328,7 @@ export class LobeGoogleAI implements LobeRuntimeAI { */ async generateObject(payload: GenerateObjectPayload, options?: GenerateObjectOptions) { // Convert OpenAI messages to Google format - const contents = await buildGoogleMessages(payload.messages); + const contents = await buildGoogleMessages(payload.messages, { model: payload.model }); const pricing = await getModelPricing(payload.model, this.provider); // Handle tools-based structured output diff --git a/packages/model-runtime/src/utils/uriParser.test.ts b/packages/model-runtime/src/utils/uriParser.test.ts index b1296d9788..993a1f7316 100644 --- a/packages/model-runtime/src/utils/uriParser.test.ts +++ b/packages/model-runtime/src/utils/uriParser.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from 'vitest'; +import { ssrfSafeFetch } from '@lobechat/ssrf-safe-fetch'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { parseDataUri } from './uriParser'; +import { parseDataUri, validateExternalUrl } from './uriParser'; + +vi.mock('@lobechat/ssrf-safe-fetch', () => ({ + ssrfSafeFetch: vi.fn(), +})); + +const mockHeadResponse = (headers: Record, status = 200) => + new Response(null, { headers, status, statusText: status === 200 ? 'OK' : 'Error' }); describe('parseDataUri', () => { it('should parse a valid data URI', () => { @@ -58,3 +66,113 @@ describe('parseDataUri', () => { expect(result).toEqual({ base64: largePadding, mimeType: 'image/png', type: 'base64' }); }); }); + +describe('validateExternalUrl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should accept a supported external URL with a valid content length', async () => { + vi.mocked(ssrfSafeFetch).mockResolvedValueOnce( + mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/png' }), + ); + + const result = await validateExternalUrl('https://example.com/image.png'); + + expect(result).toEqual({ + contentLength: 1024, + contentType: 'image/png', + isValid: true, + }); + }); + + it('should normalize image/jpg to image/jpeg', async () => { + vi.mocked(ssrfSafeFetch).mockResolvedValueOnce( + mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/jpg' }), + ); + + const result = await validateExternalUrl('https://example.com/image.jpg'); + + expect(result).toEqual({ + contentLength: 1024, + contentType: 'image/jpeg', + isValid: true, + }); + }); + + it('should accept supported external video URLs', async () => { + vi.mocked(ssrfSafeFetch).mockResolvedValueOnce( + mockHeadResponse({ 'content-length': '1024', 'content-type': 'video/mp4' }), + ); + + const result = await validateExternalUrl('https://example.com/video.mp4'); + + expect(result).toEqual({ + contentLength: 1024, + contentType: 'video/mp4', + isValid: true, + }); + }); + + it('should reject supported MIME types when Content-Length is missing', async () => { + vi.mocked(ssrfSafeFetch).mockResolvedValueOnce( + mockHeadResponse({ 'content-type': 'image/png' }), + ); + + const result = await validateExternalUrl('https://example.com/image.png'); + + expect(result).toEqual({ + contentLength: 0, + contentType: 'image/png', + isValid: false, + reason: 'Missing or invalid Content-Length header', + }); + }); + + it('should reject supported MIME types when Content-Length is invalid', async () => { + vi.mocked(ssrfSafeFetch).mockResolvedValueOnce( + mockHeadResponse({ 'content-length': 'unknown', 'content-type': 'image/png' }), + ); + + const result = await validateExternalUrl('https://example.com/image.png'); + + expect(result).toEqual({ + contentLength: 0, + contentType: 'image/png', + isValid: false, + reason: 'Missing or invalid Content-Length header', + }); + }); + + it('should reject unsupported MIME types', async () => { + vi.mocked(ssrfSafeFetch).mockResolvedValueOnce( + mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/svg+xml' }), + ); + + const result = await validateExternalUrl('https://example.com/image.svg'); + + expect(result).toEqual({ + contentLength: 1024, + contentType: 'image/svg+xml', + isValid: false, + reason: 'Unsupported content type: image/svg+xml', + }); + }); + + it('should reject files larger than the external URL limit', async () => { + const tooLarge = 101 * 1024 * 1024; + vi.mocked(ssrfSafeFetch).mockResolvedValueOnce( + mockHeadResponse({ 'content-length': String(tooLarge), 'content-type': 'image/png' }), + ); + + const result = await validateExternalUrl('https://example.com/large.png'); + + expect(result).toEqual({ + contentLength: tooLarge, + contentType: 'image/png', + isTooLarge: true, + isValid: false, + reason: `File too large: ${tooLarge} bytes (max ${100 * 1024 * 1024} bytes)`, + }); + }); +}); diff --git a/packages/model-runtime/src/utils/uriParser.ts b/packages/model-runtime/src/utils/uriParser.ts index 3af60e53a0..2119f70ca7 100644 --- a/packages/model-runtime/src/utils/uriParser.ts +++ b/packages/model-runtime/src/utils/uriParser.ts @@ -1,3 +1,5 @@ +import { ssrfSafeFetch } from '@lobechat/ssrf-safe-fetch'; + interface UriParserResult { base64: string | null; mimeType: string | null; @@ -29,3 +31,181 @@ export const parseDataUri = (dataUri: string): UriParserResult => { return { base64: null, mimeType: null, type: null }; } }; + +/** + * MIME types supported by Google Gemini External URL feature + * @see https://ai.google.dev/gemini-api/docs/file-input-methods#supported-content-types + */ +const GOOGLE_EXTERNAL_URL_SUPPORTED_TYPES = new Set([ + // Text file types + 'text/html', + 'text/css', + 'text/plain', + 'text/xml', + 'text/csv', + 'text/rtf', + 'text/javascript', + // Application file types + 'application/json', + 'application/pdf', + // Image file types + 'image/bmp', + 'image/jpeg', + 'image/png', + 'image/webp', + // Video file types + 'video/3gpp', + 'video/avi', + 'video/mp4', + 'video/mpeg', + 'video/mpg', + 'video/quicktime', + 'video/webm', + 'video/wmv', + 'video/x-flv', +]); + +const normalizeExternalContentType = (contentType: string): string => { + // Some servers return non-standard alias `image/jpg` for JPEG files. + // Normalize it to the standard type to avoid unnecessary fallback to inline base64. + if (contentType === 'image/jpg') return 'image/jpeg'; + + return contentType; +}; + +const parseContentLength = (contentLength: string | null): number | null => { + const normalized = contentLength?.trim(); + if (!normalized || !/^\d+$/.test(normalized)) return null; + + const value = Number(normalized); + return Number.isSafeInteger(value) ? value : null; +}; + +/** + * Maximum file size limits for Google Gemini file input + * @see https://ai.google.dev/gemini-api/docs/file-input-methods#method-comparison + * + * External URLs: 100MB for all file types + * Inline data: 100MB general, 50MB for PDFs + */ +const MAX_EXTERNAL_URL_SIZE = 100 * 1024 * 1024; // 100MB for external URLs (all types) +const MAX_INLINE_DATA_SIZE = 100 * 1024 * 1024; // 100MB for inline data (general) +const MAX_INLINE_PDF_SIZE = 50 * 1024 * 1024; // 50MB for inline PDFs only + +export { MAX_INLINE_DATA_SIZE, MAX_INLINE_PDF_SIZE }; + +export interface ExternalUrlValidation { + /** Content-Length from response headers */ + contentLength: number; + /** Content-Type from response headers */ + contentType: string; + /** Whether the URL was rejected due to size limit */ + isTooLarge?: boolean; + /** Whether the URL is valid for external URL usage */ + isValid: boolean; + /** Reason for invalid URL */ + reason?: string; +} + +/** + * Check if a URL is an external HTTP(S) URL + * SSRF protection is enforced by ssrfSafeFetch during validation + */ +export const isPublicExternalUrl = (url: string): boolean => { + try { + const parsed = new URL(url); + + // Only allow http and https protocols + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + + return true; + } catch { + return false; + } +}; + +/** + * Validate an external URL for Google Gemini file input + * Performs a HEAD request to check Content-Length and Content-Type + * + * @param url - The URL to validate + * @returns Validation result with content info + */ +export const validateExternalUrl = async (url: string): Promise => { + try { + // Perform HEAD request to get headers without downloading the file + const res = await ssrfSafeFetch( + url, + { + headers: { + 'User-Agent': 'LobeChat/1.0 (https://lobehub.com)', + }, + method: 'HEAD', + }, + { + allowIPAddressList: [], + allowPrivateIPAddress: false, + }, + ); + + if (!res.ok) { + return { + contentLength: 0, + contentType: '', + isValid: false, + reason: `HTTP ${res.status}: ${res.statusText}`, + }; + } + + const contentLength = parseContentLength(res.headers.get('content-length')); + const contentType = normalizeExternalContentType( + (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(), + ); + + // Check MIME type support + if (!GOOGLE_EXTERNAL_URL_SUPPORTED_TYPES.has(contentType)) { + return { + contentLength: contentLength || 0, + contentType, + isValid: false, + reason: `Unsupported content type: ${contentType}`, + }; + } + + if (contentLength === null) { + return { + contentLength: 0, + contentType, + isValid: false, + reason: 'Missing or invalid Content-Length header', + }; + } + + // Check file size - External URLs support 100MB for all file types + // (Unlike inline data where PDFs are limited to 50MB) + if (contentLength > MAX_EXTERNAL_URL_SIZE) { + return { + contentLength, + contentType, + isTooLarge: true, + isValid: false, + reason: `File too large: ${contentLength} bytes (max ${MAX_EXTERNAL_URL_SIZE} bytes)`, + }; + } + + return { + contentLength, + contentType, + isValid: true, + }; + } catch (error) { + return { + contentLength: 0, + contentType: '', + isValid: false, + reason: `Failed to validate URL: ${error instanceof Error ? error.message : String(error)}`, + }; + } +}; diff --git a/packages/utils/src/client/videoValidation.test.ts b/packages/utils/src/client/videoValidation.test.ts index 2ef4ebcb4a..cca821eb69 100644 --- a/packages/utils/src/client/videoValidation.test.ts +++ b/packages/utils/src/client/videoValidation.test.ts @@ -2,52 +2,56 @@ import { describe, expect, it } from 'vitest'; import { validateVideoFileSize } from './videoValidation'; +const createMockFile = (size: number, type: string): File => ({ size, type }) as File; + describe('validateVideoFileSize', () => { it('should return valid for non-video files', () => { - const mockFile = new File(['test'], 'test.txt', { type: 'text/plain' }); + const mockFile = createMockFile(4, 'text/plain'); const result = validateVideoFileSize(mockFile); expect(result.isValid).toBe(true); expect(result.actualSize).toBeUndefined(); + expect(result.maxSize).toBeUndefined(); }); - it('should return valid for video files under 20MB', () => { - const mockVideoFile = new File(['x'.repeat(10 * 1024 * 1024)], 'video.mp4', { - type: 'video/mp4', - }); + it('should return valid for video files under 100MB', () => { + const mockVideoFile = createMockFile(10 * 1024 * 1024, 'video/mp4'); const result = validateVideoFileSize(mockVideoFile); expect(result.isValid).toBe(true); expect(result.actualSize).toBe('10.0 MB'); }); - it('should return invalid for video files over 20MB', () => { - const mockLargeVideoFile = new File(['x'.repeat(25 * 1024 * 1024)], 'large-video.mp4', { - type: 'video/mp4', - }); + it('should return valid for video files under 100MB (25MB)', () => { + const mockLargeVideoFile = createMockFile(25 * 1024 * 1024, 'video/mp4'); const result = validateVideoFileSize(mockLargeVideoFile); - expect(result.isValid).toBe(false); + expect(result.isValid).toBe(true); expect(result.actualSize).toBe('25.0 MB'); }); - it('should return invalid for video files exactly at 20MB limit plus 1 byte', () => { - const mockBoundaryFile = new File(['x'.repeat(20 * 1024 * 1024 + 1)], 'boundary.mp4', { - type: 'video/mp4', - }); + it('should return invalid for video files over 100MB', () => { + const mockLargeVideoFile = createMockFile(120 * 1024 * 1024, 'video/mp4'); + const result = validateVideoFileSize(mockLargeVideoFile); + + expect(result.isValid).toBe(false); + expect(result.actualSize).toBe('120.0 MB'); + expect(result.maxSize).toBe('100.0 MB'); + }); + + it('should return invalid for video files exactly at 100MB limit plus 1 byte', () => { + const mockBoundaryFile = createMockFile(100 * 1024 * 1024 + 1, 'video/mp4'); const result = validateVideoFileSize(mockBoundaryFile); expect(result.isValid).toBe(false); - expect(result.actualSize).toBe('20.0 MB'); + expect(result.actualSize).toBe('100.0 MB'); }); - it('should return valid for video files exactly at 20MB limit', () => { - const mockBoundaryFile = new File(['x'.repeat(20 * 1024 * 1024)], 'boundary.mp4', { - type: 'video/mp4', - }); + it('should return valid for video files exactly at 100MB limit', () => { + const mockBoundaryFile = createMockFile(100 * 1024 * 1024, 'video/mp4'); const result = validateVideoFileSize(mockBoundaryFile); expect(result.isValid).toBe(true); - expect(result.actualSize).toBe('20.0 MB'); + expect(result.actualSize).toBe('100.0 MB'); }); }); diff --git a/packages/utils/src/client/videoValidation.ts b/packages/utils/src/client/videoValidation.ts index 68f172d503..58c2ee6e02 100644 --- a/packages/utils/src/client/videoValidation.ts +++ b/packages/utils/src/client/videoValidation.ts @@ -1,10 +1,11 @@ import { formatSize } from '../format'; -const VIDEO_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB in bytes +const VIDEO_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB in bytes export interface VideoValidationResult { actualSize?: string; isValid: boolean; + maxSize?: string; } export const validateVideoFileSize = (file: File): VideoValidationResult => { @@ -17,5 +18,6 @@ export const validateVideoFileSize = (file: File): VideoValidationResult => { return { actualSize: formatSize(file.size), isValid, + maxSize: formatSize(VIDEO_SIZE_LIMIT), }; }; diff --git a/src/features/ChatInput/ActionBar/Plus/index.tsx b/src/features/ChatInput/ActionBar/Plus/index.tsx index 9ae3a9e887..93697e8570 100644 --- a/src/features/ChatInput/ActionBar/Plus/index.tsx +++ b/src/features/ChatInput/ActionBar/Plus/index.tsx @@ -412,7 +412,10 @@ const PlusAction = memo(() => { const validation = validateVideoFileSize(file); if (!validation.isValid) { message.error( - t('upload.validation.videoSizeExceeded', { actualSize: validation.actualSize }), + t('upload.validation.videoSizeExceeded', { + actualSize: validation.actualSize, + maxSize: validation.maxSize, + }), ); return false; } diff --git a/src/features/ChatInput/ActionBar/Upload/index.tsx b/src/features/ChatInput/ActionBar/Upload/index.tsx index 4c543c0688..f570d05471 100644 --- a/src/features/ChatInput/ActionBar/Upload/index.tsx +++ b/src/features/ChatInput/ActionBar/Upload/index.tsx @@ -125,6 +125,7 @@ const FileUpload = memo(() => { message.error( t('upload.validation.videoSizeExceeded', { actualSize: validation.actualSize, + maxSize: validation.maxSize, }), ); return false; @@ -163,6 +164,7 @@ const FileUpload = memo(() => { message.error( t('upload.validation.videoSizeExceeded', { actualSize: validation.actualSize, + maxSize: validation.maxSize, }), ); return false; diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 66967c8d1e..cd3b3cdc20 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -968,7 +968,7 @@ export default { 'upload.validation.unsupportedFileType': 'Unsupported file type: {{files}}. Supported images: JPG, PNG, GIF, WebP. Supported documents include PDF, Word, Excel, PowerPoint, Markdown, text, CSV, JSON, and code files.', 'upload.validation.videoSizeExceeded': - 'Video file size must not exceed 20MB. Current file size is {{actualSize}}.', + 'Video file size must not exceed {{maxSize}}. Current file size is {{actualSize}}.', 'viewMode.fullWidth': 'Full Width', 'viewMode.normal': 'Standard', 'viewMode.wideScreen': 'Widescreen',