Merge branch 'open-webui:dev' into dev

This commit is contained in:
Kevin Rohn 2026-02-04 21:55:19 +01:00 • committed by GitHub
commit fad7514e42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 143 additions and 131 deletions

View file

@ -3462,6 +3462,12 @@ IMAGE_GENERATION_MODEL = PersistentConfig(
os.getenv("IMAGE_GENERATION_MODEL", ""),
)
# Regex pattern for models that support IMAGE_SIZE = "auto".
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv("IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN", "^gpt-image")
# Regex pattern for models that return URLs instead of base64 data.
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv("IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN", "^gpt-image")
IMAGE_SIZE = PersistentConfig(
"IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512")
)

View file

@ -14,7 +14,7 @@ import requests
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from open_webui.config import CACHE_DIR
from open_webui.config import CACHE_DIR, IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.web.utils import validate_url
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
@ -201,12 +201,12 @@ async def update_config(
set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
if (
form_data.IMAGE_SIZE == "auto"
and not form_data.IMAGE_GENERATION_MODEL.startswith("gpt-image")
and not re.match(IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL)
):
raise HTTPException(
status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT(
" (auto is only allowed with gpt-image models)."
f" (auto is only allowed with models matching {IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN})."
),
)
@ -610,9 +610,7 @@ async def image_generations(
),
**(
{}
if request.app.state.config.IMAGE_GENERATION_MODEL.startswith(
"gpt-image"
)
if re.match(IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN, request.app.state.config.IMAGE_GENERATION_MODEL)
else {"response_format": "b64_json"}
),
**(
@ -949,7 +947,7 @@ async def image_edits(
**({"size": size} if size else {}),
**(
{}
if request.app.state.config.IMAGE_EDIT_MODEL.startswith("gpt-image")
if re.match(IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN, request.app.state.config.IMAGE_EDIT_MODEL)
else {"response_format": "b64_json"}
),
}

View file

@ -22,6 +22,7 @@ from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor
from opentelemetry.trace import Span, StatusCode
from redis import Redis
from redis.cluster import RedisCluster
from requests import PreparedRequest, Response
from sqlalchemy import Engine
from fastapi import status
@ -59,16 +60,28 @@ def response_hook(span: Span, request: PreparedRequest, response: Response):
span.set_status(StatusCode.ERROR if response.status_code >= 400 else StatusCode.OK)
def redis_request_hook(span: Span, instance: Redis, args, kwargs):
def redis_request_hook(span: Span, instance: Union[Redis|RedisCluster], args, kwargs):
"""
Redis Request Hook
"""
# In cluster mode, the instance can be of two types:
# - redis.asyncio.cluster.RedisCluster
# - redis.cluster.RedisCluster
# Instead of checking the type, we check if the instance has a nodes_manager attribute.
try:
connection_kwargs: dict = instance.connection_pool.connection_kwargs
host = connection_kwargs.get("host")
port = connection_kwargs.get("port")
db = connection_kwargs.get("db")
db = ""
if hasattr(instance, 'nodes_manager'):
default_node = instance.nodes_manager.default_node
if not default_node:
return
host = default_node.host
port = default_node.port
else:
connection_kwargs: dict = instance.connection_pool.connection_kwargs
host = connection_kwargs.get("host")
port = connection_kwargs.get("port")
db = connection_kwargs.get("db")
span.set_attributes(
{
SpanAttributes.DB_INSTANCE: f"{host}/{db}",

View file

@ -258,7 +258,7 @@
class="flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full px-3 py-2 rounded-xl transition"
>
<div class="self-center">
<div class="text-sm font-medium">{$i18n.t('Leave a review for {{modelName}}', { modelName: message.model })}</div>
<div class="text-sm font-medium">{$i18n.t('Leave a public review for {{modelName}}', { modelName: message.model })}</div>
<div class="text-xs text-gray-500">{$i18n.t('Help the community discover great models')}</div>
</div>
<ChevronRight className="size-4" />

View file

@ -96,6 +96,8 @@
</DropdownMenu.Item>
{#if $config?.features.enable_community_sharing}
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<DropdownMenu.Item
type="button"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"

View file

@ -73,6 +73,7 @@
<!-- {$i18n.t('New Chat')} -->
<!-- {$i18n.t('New Temporary Chat')} -->
<!-- {$i18n.t('Delete Chat')} -->
<!-- {$i18n.t('Open Model Selector')} -->
<!-- {$i18n.t('Search')} -->
<!-- {$i18n.t('Open Settings')} -->
<!-- {$i18n.t('Show Shortcuts')} -->

View file

@ -1,5 +1,4 @@
<script lang="ts">
import TagInput from './Tags/TagInput.svelte';
import TagList from './Tags/TagList.svelte';
import { getContext, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
@ -8,9 +7,19 @@
export let tags = [];
export let suggestionTags = [];
let inputValue = '';
const addTag = () => {
const value = inputValue.trim();
if (value !== '') {
dispatch('add', value);
inputValue = '';
}
};
</script>
<ul class="flex flex-row flex-wrap gap-[0.3rem] line-clamp-1">
<div class="flex flex-wrap items-center gap-1 w-full">
<TagList
{tags}
on:delete={(e) => {
@ -18,11 +27,17 @@
}}
/>
<TagInput
label={tags.length == 0 ? $i18n.t('Add Tags') : ''}
{suggestionTags}
on:add={(e) => {
dispatch('add', e.detail);
<input
bind:value={inputValue}
class="flex-1 min-w-24 {tags.length > 0
? 'px-0.5'
: ''} text-xs bg-transparent outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-500"
placeholder={$i18n.t('Add a tag...')}
on:keydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
addTag();
}
}}
/>
</ul>
</div>

View file

@ -1,95 +1,72 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import { tags } from '$lib/stores';
import { toast } from 'svelte-sonner';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
export let label = '';
export let suggestionTags = [];
let showTagInput = false;
let tagName = '';
let showInput = false;
let inputElement: HTMLInputElement;
const addTagHandler = async () => {
tagName = tagName.trim();
if (tagName !== '') {
dispatch('add', tagName);
tagName = '';
showTagInput = false;
} else {
toast.error($i18n.t(`Invalid Tag`));
}
};
const openInput = () => {
showInput = true;
setTimeout(() => inputElement?.focus(), 0);
};
const closeInput = () => {
if (tagName.trim() === '') {
showInput = false;
}
};
</script>
<div class="px-0.5 flex {showTagInput ? 'flex-row-reverse' : ''}">
{#if showTagInput}
<div class="flex items-center">
<input
bind:value={tagName}
class=" px-2 cursor-pointer self-center text-xs h-fit bg-transparent outline-hidden line-clamp-1 w-[6.5rem]"
placeholder={$i18n.t('Add a tag')}
aria-label={$i18n.t('Add a tag')}
list="tagOptions"
on:keydown={(event) => {
if (event.key === 'Enter') {
addTagHandler();
}
}}
/>
{#if suggestionTags.length > 0}
<datalist id="tagOptions">
{#each suggestionTags as tag}
<option value={tag.name} />
{/each}
</datalist>
{/if}
<button type="button" aria-label={$i18n.t('Save Tag')} on:click={addTagHandler}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
stroke-width="2"
aria-hidden="true"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
{#if showInput}
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-200/80 dark:bg-gray-700">
<span class="text-gray-500 dark:text-blue-400">+</span>
<input
bind:this={inputElement}
bind:value={tagName}
class="w-20 text-sm bg-transparent outline-hidden text-gray-700 dark:text-blue-400 placeholder:text-gray-400 dark:placeholder:text-blue-400/50"
placeholder={$i18n.t('Add tag')}
aria-label={$i18n.t('Add a tag')}
list="tagOptions"
on:keydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
addTagHandler();
} else if (event.key === 'Escape') {
tagName = '';
showInput = false;
}
}}
on:blur={closeInput}
/>
</div>
{:else}
<button
class=" cursor-pointer self-center p-0.5 flex h-fit items-center rounded-full transition border dark:border-gray-600 border-dashed"
type="button"
aria-label={$i18n.t('Add Tag')}
on:click={() => {
showTagInput = !showTagInput;
}}
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-200/80 dark:bg-gray-700 text-gray-500 dark:text-blue-400 text-sm font-medium hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
on:click={openInput}
>
<div class=" m-auto self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-hidden="true"
fill="currentColor"
class="size-2.5 {showTagInput ? 'rotate-45' : ''} transition-all transform"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
</svg>
</div>
<span>+</span>
<span>{$i18n.t('Add Tag')}</span>
</button>
{/if}
{#if label && !showTagInput}
<span class="text-xs pl-2 self-center">{label}</span>
{/if}
</div>
{#if suggestionTags.length > 0}
<datalist id="tagOptions">
{#each suggestionTags as tag}
<option value={tag.name} />
{/each}
</datalist>
{/if}

View file

@ -3,7 +3,6 @@
const i18n = getContext('i18n');
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let tag;
@ -11,23 +10,14 @@
</script>
{#if tag}
<Tooltip content={tag.name}>
<button
aria-label={$i18n.t('Remove this tag from list')}
class="relative group/tags px-1.5 py-[0.5px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-lg bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer"
on:click={() => {
onDelete();
}}
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit">
{tag.name}
</div>
<div class="hidden group-hover/tags:block transition">
<div class="rounded-full pl-[1px] backdrop-blur-sm h-full flex self-center cursor-pointer">
<XMark className="size-3" strokeWidth="2.5" />
</div>
</div>
</button>
</Tooltip>
<button
type="button"
class="flex items-center gap-1 px-1.5 py-[1px] rounded-full bg-gray-100/50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800 text-gray-600 dark:text-gray-300 text-xs font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
on:click={() => {
onDelete();
}}
>
<span class="line-clamp-1">{tag.name}</span>
<XMark className="size-3" strokeWidth="2.5" />
</button>
{/if}

View file

@ -724,19 +724,19 @@
{/if}
</div>
<div class="my-2">
<div class="my-4">
<Knowledge bind:selectedItems={knowledge} />
</div>
<div class="my-2">
<div class="my-4">
<ToolsSelector bind:selectedToolIds={toolIds} tools={$tools ?? []} />
</div>
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0 || ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-4" />
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0}
<div class="my-2">
<div class="my-4">
<FiltersSelector
bind:selectedFilterIds={filterIds}
filters={($functions ?? []).filter((func) => func.type === 'filter')}
@ -751,7 +751,7 @@
)}
{#if toggleableFilters.length > 0}
<div class="my-2">
<div class="my-4">
<DefaultFiltersSelector
bind:selectedFilterIds={defaultFilterIds}
filters={toggleableFilters}
@ -761,7 +761,7 @@
{/if}
{#if ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<div class="my-2">
<div class="my-4">
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={($functions ?? []).filter((func) => func.type === 'action')}
@ -770,9 +770,9 @@
{/if}
{/if}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-4" />
<div class="my-2">
<div class="my-4">
<Capabilities bind:capabilities />
</div>
@ -785,19 +785,19 @@
.map(([key, value]) => key)}
{#if availableFeatures.length > 0}
<div class="my-2">
<div class="my-4">
<DefaultFeatures {availableFeatures} bind:featureIds={defaultFeatureIds} />
</div>
{/if}
{/if}
{#if capabilities.builtin_tools}
<div class="my-2">
<div class="my-4">
<BuiltinTools bind:builtinTools />
</div>
{/if}
<div class="my-2">
<div class="my-4">
<div class="flex w-full justify-between mb-1">
<div class="self-center text-xs font-medium text-gray-500">
{$i18n.t('TTS Voice')}
@ -811,7 +811,7 @@
/>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-4" />
<div class="my-2 flex justify-end">
<button

View file

@ -418,7 +418,7 @@
<div class="mt-1.5">
<Tooltip content={$i18n.t('Click to copy ID')}>
<button
class="text-xs text-gray-500 font-mono bg-gray-50 dark:bg-gray-850 px-2 py-1 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition"
class="text-xs text-gray-500 font-mono px-2 py-1 rounded-lg cursor-pointer hover:underline transition"
on:click={() => {
copyToClipboard(prompt.id);
toast.success($i18n.t('ID copied to clipboard'));
@ -465,7 +465,7 @@
</div>
{#if selectedHistoryEntry}
<span
class="text-xs text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded"
class="text-xs text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 px-1.5 rounded"
>
{selectedHistoryEntry.id.slice(0, 7)}
</span>
@ -509,9 +509,9 @@
</div>
<!-- Scrollable content -->
<div
class="bg-gray-50 dark:bg-gray-900 rounded-xl px-4 py-3 border border-gray-100 dark:border-gray-800 h-full overflow-y-auto"
class="bg-gray-50 dark:bg-gray-900 rounded-xl px-4 py-3 border border-gray-100/50 dark:border-gray-850/50 h-full overflow-y-auto"
>
<pre class="text-sm whitespace-pre-wrap font-mono pr-8">{selectedHistoryEntry?.snapshot
<pre class="text-xs whitespace-pre-wrap font-mono pr-8">{selectedHistoryEntry?.snapshot
?.content || content}</pre>
</div>
</div>
@ -623,10 +623,10 @@
<div class="flex">
<!-- Content -->
<button
class="flex-1 text-left px-3.5 py-2 mb-1 rounded-xl transition group
class="flex-1 text-left px-3.5 py-2 mb-1 rounded-2xl transition group
{selectedHistoryEntry?.id === entry.id
? 'bg-gray-50 dark:bg-gray-850'
: 'hover:bg-gray-50 dark:hover:bg-gray-850'}"
? 'bg-gray-100/50 dark:bg-gray-850/50'
: 'hover:bg-gray-100/50 dark:hover:bg-gray-850/50'}"
on:click={() => (selectedHistoryEntry = entry)}
>
<!-- Commit Message -->

View file

@ -16,6 +16,7 @@ export enum Shortcut {
NEW_CHAT = 'newChat',
NEW_TEMPORARY_CHAT = 'newTemporaryChat',
DELETE_CHAT = 'deleteChat',
OPEN_MODEL_SELECTOR = 'openModelSelector',
//Global
SEARCH = 'search',
@ -58,6 +59,11 @@ export const shortcuts: ShortcutRegistry = {
keys: ['mod', 'shift', 'Backspace', 'Delete'],
category: 'Chat'
},
[Shortcut.OPEN_MODEL_SELECTOR]: {
name: 'Open Model Selector',
keys: ['mod', 'shift', 'M'],
category: 'Chat'
},
//Global
[Shortcut.SEARCH]: {

View file

@ -230,6 +230,10 @@
event.preventDefault();
showSettings.set(false);
showShortcuts.set(false);
} else if (isShortcutMatch(event, shortcuts[Shortcut.OPEN_MODEL_SELECTOR])) {
console.log('Shortcut triggered: OPEN_MODEL_SELECTOR');
event.preventDefault();
document.getElementById('model-selector-0-button')?.click();
} else if (isShortcutMatch(event, shortcuts[Shortcut.NEW_TEMPORARY_CHAT])) {
console.log('Shortcut triggered: NEW_TEMPORARY_CHAT');
event.preventDefault();