From 9923c53c10f754a79f92654ba4a78abb50bf5578 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:40:07 +0200 Subject: [PATCH] fix: treat SVG uploads as documents instead of vision images (#30102) Uploading an .svg to a chat attached it as a vision image input, so the model received a data URI it could not decode. PIL-backed servers answered "cannot identify image file" and OpenAI answered "The image data you provided does not represent a valid image". No setting made it work. SVG now takes the ordinary file upload path, so its XML source is extracted and indexed and the model can answer questions about it. Rasterizing was the alternative and it would have discarded the part of an SVG a model reads best, the source itself. Raster formats are untouched and still go up as image inputs. A shared helper replaces the ad hoc image/ prefix checks at the points that decide image input versus document, on both ends. It normalises the content type first, because a stored "image/SVG+xml" or a trailing charset parameter slipped past a plain comparison. One behaviour change worth knowing: an SVG now needs the model to have the file upload capability, where before it rode in as an image. Fixes #30100 --- backend/open_webui/retrieval/loaders/main.py | 1 + backend/open_webui/utils/middleware.py | 5 +++-- backend/open_webui/utils/misc.py | 6 ++++++ src/lib/components/chat/Chat.svelte | 11 ++++++----- src/lib/components/chat/MessageInput.svelte | 7 ++++--- .../chat/MessageInput/InputMenu/Files.svelte | 4 +++- .../chat/MessageInput/QueuedMessageItem.svelte | 3 ++- src/lib/components/chat/Messages/UserMessage.svelte | 7 ++++--- src/lib/utils/index.ts | 5 +++++ 9 files changed, 34 insertions(+), 15 deletions(-) diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index e49faff051..74221f1aea 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -91,6 +91,7 @@ known_source_ext = [ 'yaml', 'yml', 'toml', + 'svg', ] known_archive_ext = {'docx', 'epub', 'odt', 'pptx', 'xlsx'} diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 5b59fbd7ad..472c74be1d 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -117,6 +117,7 @@ from open_webui.utils.misc import ( get_response_error_detail, get_reasoning_details, get_system_message, + is_raster_image_content_type, is_string_allowed, merge_system_messages, prepend_to_first_user_message_content, @@ -1725,7 +1726,7 @@ def get_images_from_messages(message_list): for file in message.get('files', []): if file.get('type') == 'image': message_images.append(file.get('url')) - elif file.get('content_type', '').startswith('image/'): + elif is_raster_image_content_type(file.get('content_type')): message_images.append(file.get('url')) if message_images: @@ -2440,7 +2441,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): image_files = [ f for f in message.get('files', []) - if f.get('type') == 'image' or (f.get('content_type') or '').startswith('image/') + if f.get('type') == 'image' or is_raster_image_content_type(f.get('content_type')) ] if message.get('role') == 'user' and image_files: text_content = message.get('content', '') diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 247ab72441..ad959d3bc7 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -1223,6 +1223,12 @@ def strict_match_mime_type(supported: list[str] | str, header: str) -> str | Non return None +def is_raster_image_content_type(content_type: str | None) -> bool: + """Return True if the content type is an image that decodes as a bitmap; SVG is XML.""" + base_content_type = (content_type or '').split(';')[0].strip().lower() + return base_content_type.startswith('image/') and base_content_type != 'image/svg+xml' + + def extract_urls(text: str) -> list[str]: # Regex pattern to match URLs url_pattern = re.compile(r'(https?://[^\s]+)', re.IGNORECASE) # Matches http and https URLs diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index a5df89c3a1..b966c3ef7b 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -64,7 +64,8 @@ removeAllDetails, getCodeBlockContents, displayFileHandler, - getUsageTokenCount + getUsageTokenCount, + isRasterImageContentType } from '$lib/utils'; import { AudioQueue } from '$lib/utils/audio'; import { createTemporaryChatId, isTemporaryChatId } from '$lib/utils/chatId'; @@ -2886,7 +2887,7 @@ ..._files.filter( (item) => ['doc', 'text', 'note', 'chat', 'folder', 'collection'].includes(item.type) || - (item.type === 'file' && !(item?.content_type ?? '').startsWith('image/')) + (item.type === 'file' && !isRasterImageContentType(item?.content_type)) ) ); chatFiles = chatFiles.filter( @@ -3323,7 +3324,7 @@ if (model) { const hasImages = createMessagesList(_history, parentId).some((message) => message.files?.some( - (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') + (file) => file.type === 'image' || isRasterImageContentType(file?.content_type) ) ); @@ -3445,7 +3446,7 @@ ...(userMessage?.files ?? []).filter( (item) => ['doc', 'text', 'note', 'chat', 'collection', 'folder'].includes(item.type) || - (item.type === 'file' && !(item?.content_type ?? '').startsWith('image/')) + (item.type === 'file' && !isRasterImageContentType(item?.content_type)) ) ); // Remove duplicates @@ -3496,7 +3497,7 @@ messages = messages .map((message) => { const imageFiles = (message?.files ?? []).filter( - (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') + (file) => file.type === 'image' || isRasterImageContentType(file?.content_type) ); if (message.output && message.role === 'assistant') { diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index d3cf5da88d..02988b0506 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -58,7 +58,8 @@ getUsageTokenCount, getUserPosition, getUserTimezone, - getWeekday + getWeekday, + isRasterImageContentType } from '$lib/utils'; import { uploadFile } from '$lib/apis/files'; import { getCwd, uploadNewFileToTerminal } from '$lib/apis/terminal'; @@ -1122,7 +1123,7 @@ return; } - if (file['type'].startsWith('image/')) { + if (isRasterImageContentType(file['type'])) { if (visionCapableModels.length === 0) { toast.error($i18n.t('Selected model(s) do not support image inputs')); return; @@ -1929,7 +1930,7 @@ dir={$settings?.chatDirection ?? 'auto'} > {#each files as file, fileIdx} - {#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')} + {#if file.type === 'image' || isRasterImageContentType(file?.content_type)} {@const fileUrl = file.url.startsWith('data') || file.url.startsWith('http') ? file.url diff --git a/src/lib/components/chat/MessageInput/InputMenu/Files.svelte b/src/lib/components/chat/MessageInput/InputMenu/Files.svelte index 4f0f495aaf..b387bc61d7 100644 --- a/src/lib/components/chat/MessageInput/InputMenu/Files.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu/Files.svelte @@ -1,6 +1,8 @@