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
This commit is contained in:
Classic298
2026-09-17 17:40:07 -04:00
committed by GitHub
parent d08b69025d
commit 9923c53c10
9 changed files with 34 additions and 15 deletions
@@ -91,6 +91,7 @@ known_source_ext = [
'yaml',
'yml',
'toml',
'svg',
]
known_archive_ext = {'docx', 'epub', 'odt', 'pptx', 'xlsx'}
+3 -2
View File
@@ -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', '')
+6
View File
@@ -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
+6 -5
View File
@@ -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') {
+4 -3
View File
@@ -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
@@ -1,6 +1,8 @@
<script lang="ts">
import { onDestroy, onMount, tick, getContext } from 'svelte';
import { isRasterImageContentType } from '$lib/utils';
import { searchFiles } from '$lib/apis/files';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@@ -71,7 +73,7 @@
...items,
...(res ?? []).map((file) => ({
...file,
type: file?.meta?.content_type?.startsWith('image/') ? 'image' : 'file',
type: isRasterImageContentType(file?.meta?.content_type) ? 'image' : 'file',
name: file.filename,
url: file.id,
content_type: file?.meta?.content_type,
@@ -7,6 +7,7 @@
import EditPencil from '$lib/components/icons/EditPencil.svelte';
import ArrowForward from '$lib/components/icons/ArrowForward.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { isRasterImageContentType } from '$lib/utils';
const i18n = getContext('i18n');
@@ -29,7 +30,7 @@
{#if files.length > 0}
<div class="flex items-center gap-1 shrink-0">
{#each files as file}
{#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
@@ -7,7 +7,8 @@
import {
copyToClipboard as _copyToClipboard,
formatMessageTimestamp,
formatMessageTimestampFull
formatMessageTimestampFull,
isRasterImageContentType
} from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import equal from 'fast-deep-equal';
@@ -183,7 +184,7 @@
? file.url
: `${WEBUI_API_BASE_URL}/files/${file.url}${file?.content_type ? '/content' : ''}`}
<div class={($settings?.chatBubble ?? true) ? 'self-end' : ''}>
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
{#if file.type === 'image' || isRasterImageContentType(file?.content_type)}
<Image src={fileUrl} imageClassName=" max-h-96 rounded-lg" />
{:else}
<FileItem
@@ -211,7 +212,7 @@
{#if (editedFiles ?? []).length > 0}
<div class="flex items-center flex-wrap gap-2 -mx-2 mb-1">
{#each editedFiles 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
+5
View File
@@ -969,6 +969,11 @@ export const convertOpenAIChats = (_chats) => {
return chats;
};
export const isRasterImageContentType = (contentType: string | null | undefined) => {
const baseContentType = (contentType ?? '').split(';')[0].trim().toLowerCase();
return baseContentType.startsWith('image/') && baseContentType !== 'image/svg+xml';
};
export const isValidHttpUrl = (string: string) => {
let url;